mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-11 16:30:16 -06:00
Refuse command params the description doesn't name
Callers from outside the app now get a parameter error for a key a
command's params description doesn't name, instead of the command
running with its defaults: `text.select {story, start, end}` and
`style.list {bogus: 1}` are refused with "unknown parameter `…`" and the
nearest or the accepted keys.
- engine: `cmd::params` (ported from VectorCraft #1442) reads the keys
from each params description; `check_params` / `check_keys`.
`Session::execute` itself stays lenient so old journals replay; in the
engine's own tests it checks too, as the audit of the descriptions.
- MCP: tools/call answers -32602 for an argument a tool doesn't take and
for params a command doesn't take (`execute`, every `batch` step before
the first runs); the headless backend checks as well.
- Control channel (`engine.execute`) and `designcraft-cli run --cmd`.
- Descriptions now name every key their commands read (table target
cells, anchored objects, gap.move, file.place, data merge, prefs.set,
…); type.char / type.para stay unchecked (their attributes may be the
params object itself; the setters refuse unknown attributes).
- Tests that passed keys the commands never read are fixed, and the pen
tool no longer sends the unread `notifyPen`.
Fixes #776
This commit is contained in:
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-13** · Commands refuse parameter keys their params description doesn't name (#776): the control channel, MCP (`-32602`, every `batch` step checked before the first runs) and `designcraft-cli --cmd` answer with an “unknown parameter” error naming the nearest or the accepted keys instead of running with defaults; journals still replay. Descriptions of the table, anchored-object, data-merge and placing commands now name every key they read. No readiness percentage change.
|
||||
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
|
||||
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
|
||||
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
|
||||
|
||||
@@ -252,6 +252,8 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let (id, p) = c.split_once('=').unwrap_or((&c, "{}"));
|
||||
let p: Value = serde_json::from_str(p).map_err(|e| format!("--cmd {id}: {e}"))?;
|
||||
let p = designcraft_engine::script::resolve(&p, &results).map_err(|e| format!("--cmd {id}: {e}"))?;
|
||||
// A key the command's description doesn't name is an error, not a default (#776).
|
||||
designcraft_engine::check_params(id, &p).map_err(|e| e.to_string())?;
|
||||
let r = s.execute(id, &p).map_err(|e| e.to_string())?;
|
||||
results.push(r.clone());
|
||||
if !r.is_null() {
|
||||
|
||||
@@ -67,6 +67,22 @@ fn run_cmd_references_and_describe() {
|
||||
assert!(v.as_array().unwrap().iter().all(|c| c.to_string().contains("footnote")));
|
||||
}
|
||||
|
||||
/// #776: a key the command doesn't take fails the run (`--cmd`) or the step (`script`) instead of
|
||||
/// being ignored.
|
||||
#[test]
|
||||
fn unknown_params_fail() {
|
||||
let out = cli().args(["run", "--cmd", r#"style.list={"bogus":1}"#]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown parameter `bogus`; the command takes no parameters"));
|
||||
let mut child = cli().args(["script", "-"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
child.stdin.take().unwrap().write_all(b"document.inspect\nfile.new {\"widht\": 300}\n").unwrap();
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["failedIndex"], 1);
|
||||
assert!(v.to_string().contains("did you mean `width`?"), "{v}");
|
||||
}
|
||||
|
||||
/// `designcraft-cli --help` printed usage to stderr and exited 1; asking for help is not an
|
||||
/// error (#243). It now goes to stdout and succeeds, while an unknown command keeps the same
|
||||
/// usage on stderr and fails.
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Insert Anchored Object…",
|
||||
["Object", "Anchored Object"],
|
||||
None,
|
||||
"{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative?/yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint?/refPoint?: 0..8, keepWithinColumn?} — moves the items into the text",
|
||||
"{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?} — moves the items into the text",
|
||||
has_doc,
|
||||
insert
|
||||
),
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Anchored Object Options…",
|
||||
["Object", "Anchored Object"],
|
||||
None,
|
||||
"{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative?/yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint?/refPoint?: 0..8, keepWithinColumn?}",
|
||||
"{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?}",
|
||||
has_doc,
|
||||
options
|
||||
),
|
||||
|
||||
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Data Fields",
|
||||
[],
|
||||
None,
|
||||
"{csv? | rows? | path? | bytes?, name?} → [{name, kind, uses}]; no params reads the linked source and does not attach one",
|
||||
"{csv? | rows? | path? | bytes? | base64?, name?, delimiter?, sheet?} → [{name, kind, uses}]; no params reads the linked source and does not attach one",
|
||||
has_doc,
|
||||
fields_cmd
|
||||
),
|
||||
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Merged Document…",
|
||||
[],
|
||||
None,
|
||||
"{csv? | rows? | path? | bytes?, name?, records?, one?, range?, perPage?, arrange?, insets?, columnSpacing?, rowSpacing?, fitting?, center?, linkImages?, limit?} → {records, pages, missingImages, oversetStories, warnings}",
|
||||
"{csv? | rows? | path? | bytes? | base64?, name?, delimiter?, sheet?, records?, one?, range?, perPage?, arrange?, insets?, columnSpacing?, rowSpacing?, fitting?, center?, linkImages?, limit?, spread?: removed (passing it is an error)} → {records, pages, missingImages, oversetStories, warnings}",
|
||||
has_doc,
|
||||
merge_cmd
|
||||
),
|
||||
|
||||
@@ -882,7 +882,7 @@ mod print_group_tests {
|
||||
fn print_transparency_groups_blend_in_cmyk() {
|
||||
let mut s = page_with(&logo());
|
||||
let text = |s: &mut Session| {
|
||||
let r = s.execute("file.exportPdf", &json!({"compress": false})).unwrap();
|
||||
let r = s.execute("file.exportPdf", &json!({"compressImages": false})).unwrap();
|
||||
String::from_utf8_lossy(&base64_decode(r["base64"].as_str().unwrap())).into_owned()
|
||||
};
|
||||
let t = text(&mut s);
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{DocState, EngineError, Result, Session};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "file.new", "Document…", ["File", "New"], Some("Cmd+N"),
|
||||
"{preset?: \"Letter\"|\"A4\"|…, width?, height?, pages?, facingPages?, columns?, gutter?, margins?: number|{top,bottom,inside,outside}, bleed?, title?}",
|
||||
"{preset?: \"Letter\"|\"A4\"|…, width?, height?, pages?, facingPages?, columns?, gutter?, margins?: number|{top,bottom,inside,outside}, bleed?, title?, primaryTextFrame?: bool}",
|
||||
always, file_new),
|
||||
cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample),
|
||||
cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())),
|
||||
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+D"),
|
||||
"{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename}",
|
||||
"{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, height? (video/audio), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, layoutPage?: n (IDML / .designcraft: that page's objects as a group), page?, rect?: [x0,y0,x1,y1] (text: a new frame there), autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect`",
|
||||
has_doc,
|
||||
file_place
|
||||
),
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Change All (Object)",
|
||||
[],
|
||||
None,
|
||||
"{…find.objects criteria, change: {fill?, stroke?, strokeWeight?, opacity?}} → {changed}",
|
||||
"{…the criteria of find.objects, change: {fill?, stroke?, strokeWeight?, opacity?}} → {changed}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let ids = find_objects(&s.doc()?.doc, p);
|
||||
@@ -65,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
change
|
||||
),
|
||||
cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, …} — selects the next match after the caret", has_doc, find_next),
|
||||
cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?} — selects the next match after the caret", has_doc, find_next),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Insert Math Expression…",
|
||||
["Type"],
|
||||
None,
|
||||
"{latex, size?: points (12), x?, y?, spread?} — at the text insertion point (inline), else as a frame; or {id, latex} to edit one",
|
||||
"{latex, size?: points (12), x?, y?, spread?} or {id, latex, size?} — at the text insertion point (inline), else as a frame; with `id`: edits that equation",
|
||||
has_doc,
|
||||
insert
|
||||
),
|
||||
|
||||
@@ -34,6 +34,7 @@ mod notes;
|
||||
mod object;
|
||||
mod overrides;
|
||||
mod package;
|
||||
pub mod params;
|
||||
mod path_type;
|
||||
mod paths;
|
||||
mod pdflayers;
|
||||
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Frame",
|
||||
[],
|
||||
None,
|
||||
"{spread?: index | {kind: doc|parent, index} (a parent spread: index into `parents` of document.inspect), rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}",
|
||||
"{spread?: index | {kind: doc|parent, index} (a parent spread: index into `parents` of document.inspect), rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, starInset?: 0–100 (%), text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}",
|
||||
has_doc,
|
||||
frame_create
|
||||
),
|
||||
@@ -532,7 +532,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text Frame Options…",
|
||||
["Object"],
|
||||
Some("Cmd+B"),
|
||||
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
|
||||
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, columnsKind?: fixedNumber|fixedWidth|flexibleWidth, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
|
||||
has_selection,
|
||||
text_frame_options
|
||||
),
|
||||
@@ -599,7 +599,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
fitting_options
|
||||
),
|
||||
cmd!("object.rename", "Rename", [], None, "{id, name}", has_doc, |s, p| {
|
||||
cmd!("object.rename", "Rename", [], None, "{id | ids?, name}", has_doc, |s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("").to_string();
|
||||
set_flag(s, p, move |i| i.name = name.clone(), false)
|
||||
}),
|
||||
@@ -682,7 +682,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Gap",
|
||||
[],
|
||||
None,
|
||||
"{at: [x, y] (spread), delta | dx/dy, spread?} — Gap tool: the gap at `at` between objects (or an object and the page edge) moves by `delta`; the objects on both sides resize → {axis, changed}",
|
||||
"{at: [x, y] (spread), delta?: pt | dx? | dy?: a drag (the part along the gap's axis counts), spread?} — Gap tool: the gap at `at` between objects (or an object and the page edge) moves by `delta`; the objects on both sides resize → {axis, changed}",
|
||||
has_doc,
|
||||
gap_move
|
||||
),
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! The parameter keys a command accepts, read from its params description (#776).
|
||||
//!
|
||||
//! A description starts with the params object: `{x, width?: pt, name?|title?: …, sides=6, …}`, or
|
||||
//! with the objects a command takes instead of one another: `{path} or {name, dataBase64}`,
|
||||
//! `{name} | {names: […]}`. Each entry of an object starts with a key, or with alternatives split
|
||||
//! by `|`; a `?` marks an optional one, and a `:`, `=` or `(` starts what the entry says about it.
|
||||
//! An entry that starts with `…` stands for keys listed elsewhere: those of each command it names
|
||||
//! after `as` or `of` (`…settings as document.new`), or, naming none, any key. Objects inside a
|
||||
//! value aren't described key by key, so only the top level is checked.
|
||||
//!
|
||||
//! Callers outside the app (the control channel, MCP, the CLI) are answered with a parameter error
|
||||
//! for a key the description doesn't name ([`check_params`]): a misspelt `widht` no longer runs the
|
||||
//! command with its default. [`Session::execute`](crate::Session::execute) itself takes what it is
|
||||
//! given, so the journal of an older version still replays.
|
||||
//!
|
||||
//! Ported from VectorCraft (storytold/vectorcraft#1442).
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{bad, find_command};
|
||||
use crate::Result;
|
||||
|
||||
/// What a params description says about the keys of the params object.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct ParamKeys<'a> {
|
||||
/// The keys it names.
|
||||
pub keys: Vec<&'a str>,
|
||||
/// Its `…` entries, without the `…`.
|
||||
pub more: Vec<&'a str>,
|
||||
}
|
||||
|
||||
/// The text between the braces of the object `doc` starts with and what follows it, or why it
|
||||
/// has none.
|
||||
fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> {
|
||||
// (Named, not written in the `Err`: these are for the tests of the descriptions, not messages
|
||||
// the app shows, which the message catalogs would have to translate.)
|
||||
let (no_object, not_closed) = ("the description doesn't start with the params object `{…}`", "the params object `{…}` isn't closed");
|
||||
if !doc.starts_with('{') {
|
||||
return Err(no_object.into());
|
||||
}
|
||||
let (mut depth, mut quote) = (0usize, None);
|
||||
for (i, c) in doc.char_indices() {
|
||||
match (quote, c) {
|
||||
(Some(q), _) if c == q => quote = None,
|
||||
(Some(_), _) => {}
|
||||
(None, '"' | '`') => quote = Some(c),
|
||||
(None, '{' | '[' | '(') => depth += 1,
|
||||
(None, '}' | ']' | ')') => {
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
return Ok((doc.get(1..i).unwrap_or_default(), doc.get(i + 1..).unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(not_closed.into())
|
||||
}
|
||||
|
||||
/// `s` split at each `sep` that is outside brackets, quotes and backticks.
|
||||
fn split_top(s: &str, sep: char) -> Vec<&str> {
|
||||
let (mut out, mut depth, mut quote, mut start) = (Vec::new(), 0usize, None, 0);
|
||||
for (i, c) in s.char_indices() {
|
||||
match (quote, c) {
|
||||
(Some(q), _) if c == q => quote = None,
|
||||
(Some(_), _) => {}
|
||||
(None, '"' | '`') => quote = Some(c),
|
||||
(None, '{' | '[' | '(') => depth += 1,
|
||||
(None, '}' | ']' | ')') => depth = depth.saturating_sub(1),
|
||||
(None, _) if c == sep && depth == 0 => {
|
||||
out.push(s.get(start..i).unwrap_or_default());
|
||||
start = i + c.len_utf8();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push(s.get(start..).unwrap_or_default());
|
||||
out
|
||||
}
|
||||
|
||||
fn is_key(s: &str) -> bool {
|
||||
let mut chars = s.chars();
|
||||
chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
/// The keys a params description names, or what in it doesn't read as one.
|
||||
pub fn doc_keys(doc: &str) -> std::result::Result<ParamKeys<'_>, String> {
|
||||
let mut out = ParamKeys::default();
|
||||
let (mut body, mut rest) = object_body(doc)?;
|
||||
let mut entries = split_top(body, ',');
|
||||
// `{…} or {…}`, `{…} | {…}`: the objects the command takes instead.
|
||||
while let Some(next) = rest.trim_start().strip_prefix("or ").or_else(|| rest.trim_start().strip_prefix("| ")).filter(|r| r.starts_with('{')) {
|
||||
(body, rest) = object_body(next)?;
|
||||
entries.extend(split_top(body, ','));
|
||||
}
|
||||
for entry in entries.into_iter().map(str::trim).filter(|e| !e.is_empty()) {
|
||||
if let Some(more) = entry.strip_prefix('…') {
|
||||
out.more.push(more.trim());
|
||||
continue;
|
||||
}
|
||||
// `a?|b?: note` names two keys, and so does `a?: note | b?: note`; in `a?: x|y` the `y`
|
||||
// is a value.
|
||||
let mut noted = false;
|
||||
for alternative in split_top(entry, '|') {
|
||||
let head = alternative.split([':', '=', '(']).next().unwrap_or_default();
|
||||
let rest = alternative.get(head.len()..).unwrap_or_default();
|
||||
let name = head.trim().trim_end_matches('?').trim_end();
|
||||
if !noted {
|
||||
if !is_key(name) {
|
||||
let start: String = entry.chars().take(60).collect();
|
||||
let why = format!("`{start}` doesn't start with a parameter name");
|
||||
return Err(why);
|
||||
}
|
||||
out.keys.push(name);
|
||||
noted = !rest.is_empty();
|
||||
} else if is_key(name) && (head.trim_end().ends_with('?') || rest.starts_with(':')) {
|
||||
out.keys.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The commands whose keys a `…` entry stands for: those it names after `as` or `of` (`settings as
|
||||
/// file.new`, `the PDF options of document.exportPdf`), not one it only points to (`see …`).
|
||||
fn named_commands(more: &str) -> impl Iterator<Item = &'static super::CommandSpec> + '_ {
|
||||
let words: Vec<&str> = more.split_whitespace().collect();
|
||||
let id = |w: &str| find_command(w.trim_matches(|c: char| !c.is_ascii_alphanumeric()));
|
||||
(1..words.len()).filter_map(move |i| id(words.get(i)?).filter(|_| matches!(words.get(i - 1), Some(&"as" | &"of"))))
|
||||
}
|
||||
|
||||
/// Every key command `doc` accepts, the keys of the commands its `…` entries name included.
|
||||
/// `None`: any key (a `…` entry naming no command, or a description that doesn't parse, which
|
||||
/// the tests rule out).
|
||||
fn accepted(doc: &'static str, depth: usize) -> Option<Vec<&'static str>> {
|
||||
let parsed = doc_keys(doc).ok()?;
|
||||
let mut keys = parsed.keys;
|
||||
// A command that acts on `ids` takes one object as `id` too (see `cmd::targets`).
|
||||
if keys.contains(&"ids") && !keys.contains(&"id") {
|
||||
keys.push("id");
|
||||
}
|
||||
for more in parsed.more {
|
||||
let mut named = named_commands(more).peekable();
|
||||
// A reference that leads back here would never end: descriptions refer one or two deep.
|
||||
if named.peek().is_none() || depth > 4 {
|
||||
return None;
|
||||
}
|
||||
for spec in named {
|
||||
// A command that isn't checked yet doesn't vouch for the ones that take its keys.
|
||||
if UNCHECKED.contains(&spec.id) {
|
||||
return None;
|
||||
}
|
||||
keys.extend(accepted(spec.params, depth + 1)?);
|
||||
}
|
||||
}
|
||||
Some(keys)
|
||||
}
|
||||
|
||||
/// The edits (insert, delete, replace, swap of neighbours) that turn `a` into `b`, ignoring case.
|
||||
fn distance(a: &str, b: &str) -> usize {
|
||||
let (a, b): (Vec<char>, Vec<char>) = (a.chars().map(|c| c.to_ascii_lowercase()).collect(), b.chars().map(|c| c.to_ascii_lowercase()).collect());
|
||||
let (mut before, mut prev): (Vec<usize>, Vec<usize>) = (Vec::new(), (0..=b.len()).collect());
|
||||
for (i, ca) in a.iter().enumerate() {
|
||||
let mut row = vec![i + 1];
|
||||
for (j, cb) in b.iter().enumerate() {
|
||||
let at = |r: &[usize], k: usize| r.get(k).copied().unwrap_or(usize::MAX / 2);
|
||||
let mut d = (at(&prev, j) + usize::from(ca != cb)).min(at(&prev, j + 1) + 1).min(at(&row, j) + 1);
|
||||
if i > 0 && j > 0 && a.get(i - 1) == Some(cb) && b.get(j - 1) == Some(ca) {
|
||||
d = d.min(at(&before, j - 1) + 1);
|
||||
}
|
||||
row.push(d);
|
||||
}
|
||||
before = std::mem::replace(&mut prev, row);
|
||||
}
|
||||
prev.last().copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The key of `keys` a misspelt `key` most likely meant: the nearest one, when it is near.
|
||||
fn nearest<'a>(key: &str, keys: &[&'a str]) -> Option<&'a str> {
|
||||
let near = keys.iter().map(|k| (distance(key, k), *k)).min()?;
|
||||
(near.0 <= (key.chars().count() / 3).max(1)).then_some(near.1)
|
||||
}
|
||||
|
||||
/// `params` has only keys the description `doc` of command `id` names, or the parameter error
|
||||
/// naming the first one it doesn't, with the nearest accepted key or the accepted ones.
|
||||
pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> {
|
||||
let Value::Object(given) = params else { return Ok(()) };
|
||||
if given.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(keys) = accepted(doc, 0) else { return Ok(()) };
|
||||
match given.keys().find(|k| !keys.contains(&k.as_str())) {
|
||||
Some(key) => Err(unknown(id, key, &keys)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of
|
||||
/// them, or all of them.
|
||||
fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
|
||||
bad(
|
||||
id,
|
||||
match nearest(key, keys) {
|
||||
Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"),
|
||||
None if keys.is_empty() => format!("unknown parameter `{key}`; the command takes no parameters"),
|
||||
None => format!("unknown parameter `{key}`; the command takes: {}", keys.join(", ")),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Commands whose params aren't checked yet (#776): they read keys in ways the audit of the
|
||||
/// descriptions couldn't follow (a list of keys in a loop, a whole object read as settings), so
|
||||
/// their descriptions aren't proven to name every key they take. Sorted; take a command out once
|
||||
/// its description is checked against its code.
|
||||
pub const UNCHECKED: &[&str] = &[
|
||||
// Take the attributes under `attrs` or as the params object itself; the attribute setters
|
||||
// refuse a key they don't know.
|
||||
"type.char",
|
||||
"type.para",
|
||||
];
|
||||
|
||||
/// [`check_keys`] for an engine command. A command the engine doesn't have passes: running it
|
||||
/// reports that.
|
||||
pub fn check_params(id: &str, params: &Value) -> Result<()> {
|
||||
let Some(spec) = find_command(id) else { return Ok(()) };
|
||||
if UNCHECKED.contains(&id) {
|
||||
return Ok(());
|
||||
}
|
||||
check_keys(id, spec.params, params)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Preferences",
|
||||
[],
|
||||
None,
|
||||
"{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], showFontNamesInEnglish?} → all application preferences",
|
||||
"{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, recoveryMinutes?} → all application preferences",
|
||||
super::always,
|
||||
|s, p| {
|
||||
let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?;
|
||||
|
||||
@@ -1260,7 +1260,12 @@ mod color_tests {
|
||||
let mut session = fill_session();
|
||||
session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap();
|
||||
session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap();
|
||||
assert_fill_rejected(&mut session, command, json!({"name": "Existing", "rename": "Renamed", "chars": {"fill": fill, "size": 24}}));
|
||||
let mut params = json!({"name": "Existing", "chars": {"fill": fill, "size": 24}});
|
||||
// Only the edit commands take `rename`.
|
||||
if command.ends_with(".edit") {
|
||||
params["rename"] = json!("Renamed");
|
||||
}
|
||||
assert_fill_rejected(&mut session, command, params);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1611,7 +1616,7 @@ mod export_tag_tests {
|
||||
assert!(html.contains("<h1 class=\"chapter\">Origins</h1>"), "{html}");
|
||||
assert!(html.contains("<strong class=\"key-term\">word</strong>"), "{html}");
|
||||
// Tagged PDF: a heading element and a paragraph element.
|
||||
let r = s.execute("file.exportPdf", &json!({"tagged": true, "compress": false})).unwrap();
|
||||
let r = s.execute("file.exportPdf", &json!({"tagged": true, "compressImages": false})).unwrap();
|
||||
let pdf = super::super::file::base64_decode(r["base64"].as_str().unwrap());
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
assert!(text.contains("/S/H1") && text.contains("/S/P"), "structure elements");
|
||||
@@ -1728,12 +1733,20 @@ mod sample_color_tests {
|
||||
#[test]
|
||||
fn sampling_and_explicit_colour_use_the_same_text_application_path() {
|
||||
for command in ["color.sample", "object.color"] {
|
||||
// Each command gets its own key: the eyedropper samples at `at`, object.color takes `color`.
|
||||
let params = |at: [f64; 2], color: &str, extra: serde_json::Value| {
|
||||
let mut p = if command == "color.sample" { json!({"at": at}) } else { json!({"color": color}) };
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
p
|
||||
};
|
||||
let (mut s, _) = session_with_image();
|
||||
let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text"})).unwrap();
|
||||
let story = designcraft_doc::StoryId(f["story"].as_u64().unwrap());
|
||||
s.execute("text.select", &json!({"story": story.0, "anchor": 0})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute(command, &json!({"at": [100, 100], "color": "#ff0000"})).unwrap();
|
||||
let r = s.execute(command, ¶ms([100.0, 100.0], "#ff0000", json!({}))).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}");
|
||||
s.execute("text.insert", &json!({"text": "Hi"})).unwrap();
|
||||
let text = s.doc().unwrap().doc.story(story).unwrap();
|
||||
@@ -1741,11 +1754,11 @@ mod sample_color_tests {
|
||||
// Explicit ids override text targeting, including with a text selection active.
|
||||
let fid = ItemId(f["id"].as_u64().unwrap());
|
||||
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap();
|
||||
let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "id": fid.0})).unwrap();
|
||||
let r = s.execute(command, ¶ms([180.0, 100.0], "#0000ff", json!({"id": fid.0}))).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.item(fid).unwrap().fill.swatch, r["swatch"].as_str().unwrap());
|
||||
let text = s.doc().unwrap().doc.story(story).unwrap();
|
||||
assert_ne!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "text unchanged: {command}");
|
||||
let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "target": "stroke"})).unwrap();
|
||||
let r = s.execute(command, ¶ms([180.0, 100.0], "#0000ff", json!({"target": "stroke"}))).unwrap();
|
||||
let text = s.doc().unwrap().doc.story(story).unwrap();
|
||||
assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}");
|
||||
}
|
||||
|
||||
+164
-38
@@ -29,7 +29,7 @@ fn style_specs() -> Vec<CommandSpec> {
|
||||
"New Cell Style…",
|
||||
[],
|
||||
None,
|
||||
"{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, stroke?: {weight, color, tint?}, paragraphStyle?} → {name}",
|
||||
"{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, stroke?: {weight, color, tint?}, paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}",
|
||||
has_doc,
|
||||
|s, p| cell_style_create(s, p)
|
||||
),
|
||||
@@ -38,7 +38,7 @@ fn style_specs() -> Vec<CommandSpec> {
|
||||
"Apply Cell Style",
|
||||
[],
|
||||
None,
|
||||
"{name, story?, table?} — the target cells (the whole table when the cursor is in it)",
|
||||
"{name, story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} — the target cells (the whole table when the cursor is in it)",
|
||||
in_table,
|
||||
cell_style_apply
|
||||
),
|
||||
@@ -56,11 +56,19 @@ fn style_specs() -> Vec<CommandSpec> {
|
||||
"New Table Style…",
|
||||
[],
|
||||
None,
|
||||
"{name, header?, body?, footer?, leftColumn?, rightColumn?: cell style names, border?: {weight, color}, altRows?: {first, firstColor, next, nextColor}, spaceBefore?, spaceAfter?} → {name}",
|
||||
"{name, basedOn?: table style name, header?, body?, footer?, leftColumn?, rightColumn?: cell style names, border?: {weight, color}, altRows?: {first, firstColor, next, nextColor}, spaceBefore?, spaceAfter?} → {name}",
|
||||
has_doc,
|
||||
table_style_create
|
||||
),
|
||||
cmd!("style.table.apply", "Apply Table Style", [], None, "{name, story?, table?}", in_table, table_style_apply),
|
||||
cmd!(
|
||||
"style.table.apply",
|
||||
"Apply Table Style",
|
||||
[],
|
||||
None,
|
||||
"{name, story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?}",
|
||||
in_table,
|
||||
table_style_apply
|
||||
),
|
||||
cmd!(
|
||||
"style.table.edit",
|
||||
"Table Style Options…",
|
||||
@@ -80,7 +88,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Create Table",
|
||||
[],
|
||||
Some("Cmd+Alt+Shift+T"),
|
||||
"{rows?: body rows (4), cols? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point",
|
||||
"{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point",
|
||||
has_text,
|
||||
insert
|
||||
),
|
||||
@@ -89,7 +97,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Convert Text to Table",
|
||||
["Table"],
|
||||
None,
|
||||
"{columnSeparator?: tab|comma} — selected paragraphs become rows",
|
||||
"{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows",
|
||||
has_text,
|
||||
convert_from_text
|
||||
),
|
||||
@@ -98,20 +106,100 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Convert Table to Text",
|
||||
["Table"],
|
||||
None,
|
||||
"{} — cells separated by tabs, rows by paragraphs",
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs",
|
||||
in_table,
|
||||
convert_to_text
|
||||
),
|
||||
cmd!("table.insertRow", "Insert Row", [], None, "{where?: above|below, count? (1)}", in_table, |s, p| insert_row(s, p, None)),
|
||||
cmd!("table.insertRowAbove", "Insert Row Above", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_row(s, p, Some(true))),
|
||||
cmd!("table.insertRowBelow", "Insert Row Below", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_row(s, p, Some(false))),
|
||||
cmd!("table.insertColumn", "Insert Column", [], None, "{where?: left|right, count? (1), width?}", in_table, |s, p| insert_col(s, p, None)),
|
||||
cmd!("table.insertColumnLeft", "Insert Column Left", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_col(s, p, Some(true))),
|
||||
cmd!("table.insertColumnRight", "Insert Column Right", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_col(s, p, Some(false))),
|
||||
cmd!("table.deleteRow", "Delete Row", ["Table", "Delete"], None, "{} — the rows of the target cells", in_table, delete_rows),
|
||||
cmd!("table.deleteColumn", "Delete Column", ["Table", "Delete"], None, "{} — the columns of the target cells", in_table, delete_cols),
|
||||
cmd!("table.delete", "Delete Table", ["Table", "Delete"], None, "{}", in_table, delete_table),
|
||||
cmd!("table.merge", "Merge Cells", ["Table"], None, "{} — merge the target cell range", in_table, merge),
|
||||
cmd!(
|
||||
"table.insertRow",
|
||||
"Insert Row",
|
||||
[],
|
||||
None,
|
||||
"{where?: above|below, count? (1), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
|
||||
in_table,
|
||||
|s, p| insert_row(s, p, None)
|
||||
),
|
||||
cmd!(
|
||||
"table.insertRowAbove",
|
||||
"Insert Row Above",
|
||||
["Table", "Insert"],
|
||||
None,
|
||||
"{count?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
|
||||
in_table,
|
||||
|s, p| insert_row(s, p, Some(true))
|
||||
),
|
||||
cmd!(
|
||||
"table.insertRowBelow",
|
||||
"Insert Row Below",
|
||||
["Table", "Insert"],
|
||||
None,
|
||||
"{count?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
|
||||
in_table,
|
||||
|s, p| insert_row(s, p, Some(false))
|
||||
),
|
||||
cmd!(
|
||||
"table.insertColumn",
|
||||
"Insert Column",
|
||||
[],
|
||||
None,
|
||||
"{where?: left|right, count? (1), width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
|
||||
in_table,
|
||||
|s, p| insert_col(s, p, None)
|
||||
),
|
||||
cmd!(
|
||||
"table.insertColumnLeft",
|
||||
"Insert Column Left",
|
||||
["Table", "Insert"],
|
||||
None,
|
||||
"{count?, width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
|
||||
in_table,
|
||||
|s, p| insert_col(s, p, Some(true))
|
||||
),
|
||||
cmd!(
|
||||
"table.insertColumnRight",
|
||||
"Insert Column Right",
|
||||
["Table", "Insert"],
|
||||
None,
|
||||
"{count?, width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
|
||||
in_table,
|
||||
|s, p| insert_col(s, p, Some(false))
|
||||
),
|
||||
cmd!(
|
||||
"table.deleteRow",
|
||||
"Delete Row",
|
||||
["Table", "Delete"],
|
||||
None,
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the rows of the target cells",
|
||||
in_table,
|
||||
delete_rows
|
||||
),
|
||||
cmd!(
|
||||
"table.deleteColumn",
|
||||
"Delete Column",
|
||||
["Table", "Delete"],
|
||||
None,
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the columns of the target cells",
|
||||
in_table,
|
||||
delete_cols
|
||||
),
|
||||
cmd!(
|
||||
"table.delete",
|
||||
"Delete Table",
|
||||
["Table", "Delete"],
|
||||
None,
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
|
||||
in_table,
|
||||
delete_table
|
||||
),
|
||||
cmd!(
|
||||
"table.merge",
|
||||
"Merge Cells",
|
||||
["Table"],
|
||||
None,
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — merge the target cell range",
|
||||
in_table,
|
||||
merge
|
||||
),
|
||||
cmd!(
|
||||
"table.dropCells",
|
||||
"Drag Rows or Columns",
|
||||
@@ -126,7 +214,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Convert Cell to Graphic Cell",
|
||||
["Table", "Convert Cell Type"],
|
||||
None,
|
||||
"{path | base64+name, fit?: proportional|fill (default proportional)} — an image in the target cell (its text is kept but hidden)",
|
||||
"{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)",
|
||||
in_table,
|
||||
place_graphic
|
||||
),
|
||||
@@ -135,7 +223,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Convert Cell to Text Cell",
|
||||
["Table", "Convert Cell Type"],
|
||||
None,
|
||||
"{} — drop the target cells' graphics",
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — drop the target cells' graphics",
|
||||
in_table,
|
||||
|s, p| {
|
||||
let g = target(s, p, "table.textCell")?;
|
||||
@@ -156,29 +244,43 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Move Row",
|
||||
[],
|
||||
None,
|
||||
"{from?, to} (0-based; from defaults to the target row) — like dragging a row",
|
||||
"{from?, to, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} (0-based; from defaults to the target row) — like dragging a row",
|
||||
in_table,
|
||||
|s, p| move_rc(s, p, true)
|
||||
),
|
||||
cmd!("table.moveColumn", "Move Column", [], None, "{from?, to} (0-based; from defaults to the target column)", in_table, |s, p| move_rc(
|
||||
s, p, false
|
||||
)),
|
||||
cmd!(
|
||||
"table.moveColumn",
|
||||
"Move Column",
|
||||
[],
|
||||
None,
|
||||
"{from?, to, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} (0-based; from defaults to the target column)",
|
||||
in_table,
|
||||
|s, p| move_rc(s, p, false)
|
||||
),
|
||||
cmd!(
|
||||
"table.sortRows",
|
||||
"Sort",
|
||||
["Table"],
|
||||
None,
|
||||
"{column?: 0-based (default: the target cell's), descending?: bool} — body rows by that column's text (numbers numerically)",
|
||||
"{column?: 0-based (default: the target cell's), descending?: bool, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — body rows by that column's text (numbers numerically)",
|
||||
in_table,
|
||||
sort_rows
|
||||
),
|
||||
cmd!("table.unmerge", "Unmerge Cells", ["Table"], None, "{}", in_table, unmerge),
|
||||
cmd!(
|
||||
"table.unmerge",
|
||||
"Unmerge Cells",
|
||||
["Table"],
|
||||
None,
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
|
||||
in_table,
|
||||
unmerge
|
||||
),
|
||||
cmd!(
|
||||
"table.splitHorizontally",
|
||||
"Split Cell Horizontally",
|
||||
["Table"],
|
||||
None,
|
||||
"{} — each target cell becomes two, one above the other",
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — each target cell becomes two, one above the other",
|
||||
in_table,
|
||||
|s, p| split(s, p, true)
|
||||
),
|
||||
@@ -187,7 +289,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Split Cell Vertically",
|
||||
["Table"],
|
||||
None,
|
||||
"{} — each target cell becomes two, side by side",
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — each target cell becomes two, side by side",
|
||||
in_table,
|
||||
|s, p| split(s, p, false)
|
||||
),
|
||||
@@ -196,19 +298,43 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Cell Options",
|
||||
[],
|
||||
None,
|
||||
"{fill?: swatch, tint?, insets?: n | [t,l,b,r], vj?: top|center|bottom|justify, text?, stroke?: {weight?, color?, tint?, type?: solid|dashed|dotted, edges?: all|outer|inner|top|left|bottom|right}}",
|
||||
"{fill?: swatch, tint?, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, text?, stroke?: {weight?, color?, tint?, type?: solid|dashed|dotted, edges?: all|outer|inner|top|left|bottom|right}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
|
||||
in_table,
|
||||
set_cell
|
||||
),
|
||||
cmd!("table.setRowHeight", "Row Height", [], None, "{height, mode?: atLeast|exactly}", in_table, set_row_height),
|
||||
cmd!("table.setColumnWidth", "Column Width", [], None, "{width}", in_table, set_col_width),
|
||||
cmd!("table.distributeColumns", "Distribute Columns Evenly", ["Table"], None, "{}", in_table, distribute_cols),
|
||||
cmd!(
|
||||
"table.setRowHeight",
|
||||
"Row Height",
|
||||
[],
|
||||
None,
|
||||
"{height, mode?: atLeast|exactly, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
|
||||
in_table,
|
||||
set_row_height
|
||||
),
|
||||
cmd!(
|
||||
"table.setColumnWidth",
|
||||
"Column Width",
|
||||
[],
|
||||
None,
|
||||
"{width, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
|
||||
in_table,
|
||||
set_col_width
|
||||
),
|
||||
cmd!(
|
||||
"table.distributeColumns",
|
||||
"Distribute Columns Evenly",
|
||||
["Table"],
|
||||
None,
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
|
||||
in_table,
|
||||
distribute_cols
|
||||
),
|
||||
cmd!(
|
||||
"table.distributeRows",
|
||||
"Distribute Rows Evenly",
|
||||
["Table"],
|
||||
None,
|
||||
"{story?, table?, rows?: [a,b]} — equal fixed heights preserving the selected rows' composed total height; may overset cell text; unmerge row-spanning cells first",
|
||||
"{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} — equal fixed heights preserving the selected rows' composed total height; may overset cell text; unmerge row-spanning cells first",
|
||||
in_table,
|
||||
distribute_rows
|
||||
),
|
||||
@@ -217,23 +343,23 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Table Options",
|
||||
[],
|
||||
None,
|
||||
"{direction?: leftToRight|rightToLeft, border?: {weight?, color?, tint?, type?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null}",
|
||||
"{direction?: leftToRight|rightToLeft, border?: {weight?, color?, tint?, type?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
|
||||
in_table,
|
||||
options
|
||||
),
|
||||
cmd!(noundo "table.select", "Select Cells", [], None, "{story?, table?, rows?: [a,b], cols?: [a,b], what?: cell|row|column|table}", in_table_or_ids, select),
|
||||
cmd!(noundo "table.selectTable", "Select Table", ["Table", "Select"], None, "{}", in_table, |s, p| {
|
||||
cmd!(noundo "table.select", "Select Cells", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?, what?: cell|row|column|table}", in_table_or_ids, select),
|
||||
cmd!(noundo "table.selectTable", "Select Table", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| {
|
||||
select(s, &super::with_param(p, "what", json!("table")))
|
||||
}),
|
||||
cmd!(noundo "table.selectRow", "Select Row", ["Table", "Select"], None, "{}", in_table, |s, p| {
|
||||
cmd!(noundo "table.selectRow", "Select Row", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| {
|
||||
select(s, &super::with_param(p, "what", json!("row")))
|
||||
}),
|
||||
cmd!(noundo "table.selectColumn", "Select Column", ["Table", "Select"], None, "{}", in_table, |s, p| {
|
||||
cmd!(noundo "table.selectColumn", "Select Column", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| {
|
||||
select(s, &super::with_param(p, "what", json!("column")))
|
||||
}),
|
||||
cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)),
|
||||
cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)),
|
||||
cmd!(query "table.get", "Get Table", [], None, "{story?, table?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get),
|
||||
cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Story Column Direction",
|
||||
["Type", "Story Column Direction"],
|
||||
None,
|
||||
"{story?, direction: leftToRight|rightToLeft} — column progression, independent of paragraph direction",
|
||||
"{story?, frame?: a text frame of the story, direction: leftToRight|rightToLeft} — column progression, independent of paragraph direction",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let sid = story_of(s, p).ok_or_else(|| bad("story.setDirection", "no story"))?;
|
||||
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
v["lines"] = json!(cs.line_count());
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!("type.fillWithPlaceholder", "Fill with Placeholder Text", ["Type"], None, "{frame?}", has_text_or_frames, fill_placeholder),
|
||||
cmd!("type.fillWithPlaceholder", "Fill with Placeholder Text", ["Type"], None, "{frame?, story?}", has_text_or_frames, fill_placeholder),
|
||||
cmd!(
|
||||
"type.char",
|
||||
"Character Formatting",
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Insert Cross-Reference…",
|
||||
["Type", "Hyperlinks & Cross-References"],
|
||||
None,
|
||||
"{anchor? | paragraph?: text to find (first paragraph containing it) | story? + para? (0-based), format?: name (default Full Paragraph & Page Number)} — at the insertion point; the text updates itself as the destination moves",
|
||||
"{anchor? | paragraph?: text to find (first paragraph containing it) | story?: with para? | para?: 0-based paragraph of `story`, format?: name (default Full Paragraph & Page Number)} — at the insertion point; the text updates itself as the destination moves",
|
||||
in_story_text,
|
||||
insert
|
||||
),
|
||||
|
||||
@@ -25,6 +25,7 @@ use designcraft_compose::Cache;
|
||||
use designcraft_doc::{Document, LayerId, Selection};
|
||||
use serde_json::Value;
|
||||
|
||||
pub use cmd::params::{check_keys, check_params};
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use designcraft_compose as compose;
|
||||
pub use designcraft_doc as doc;
|
||||
@@ -402,6 +403,10 @@ impl Session {
|
||||
|
||||
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
// The crate's own tests are the audit of the params descriptions (#776): a key a test
|
||||
// passes that its command's description doesn't name fails the test.
|
||||
#[cfg(test)]
|
||||
check_params(id, params)?;
|
||||
if let Err(e) = (spec.enabled)(self) {
|
||||
// A command that takes `ids` acts on the objects the call names, so it needs no
|
||||
// selection: with a text caret or nothing selected, `{"ids": [5]}` still runs.
|
||||
@@ -561,4 +566,6 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_idml;
|
||||
#[cfg(test)]
|
||||
mod tests_params;
|
||||
#[cfg(test)]
|
||||
mod tests_table;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! The params descriptions name the keys their commands accept (#776).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::cmd::params::{UNCHECKED, check_keys, check_params, doc_keys};
|
||||
use crate::{EngineError, Session, command_specs, find_command};
|
||||
|
||||
fn keys(doc: &str) -> Vec<&str> {
|
||||
doc_keys(doc).unwrap().keys
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_description_names_its_keys() {
|
||||
assert_eq!(keys("{} → {id}"), Vec::<&str>::new());
|
||||
assert_eq!(
|
||||
keys("{x, y, width?: pt=612|\"210 mm\", sides=6, name?|title?: a name (default: Untitled-N)} → {index}"),
|
||||
["x", "y", "width", "sides", "name", "title"]
|
||||
);
|
||||
// Brackets, quotes and backticks keep their commas and bars; a nested object isn't listed.
|
||||
assert_eq!(
|
||||
keys("{at: [x, y] (page, not view), stops: [{position: pt, leader?: \", \"}], csv?: a `|, ` in a label is a break, on?: bool}"),
|
||||
["at", "stops", "csv", "on"]
|
||||
);
|
||||
// Alternatives after a note are keys when they have their own `?` or `:`; else values.
|
||||
assert_eq!(
|
||||
keys("{steps?: n | distance?: pt | smooth?: bool, spacing?: smooth|steps|distance, path? | data?: text | dataBase64?}"),
|
||||
["steps", "distance", "smooth", "spacing", "path", "data", "dataBase64"]
|
||||
);
|
||||
assert_eq!(keys("{latex, size?} or {id, latex}"), ["latex", "size", "id", "latex"]);
|
||||
let more = doc_keys("{name, …same as style.cell.create, …the format's options}").unwrap();
|
||||
assert_eq!((more.keys, more.more), (vec!["name"], vec!["same as style.cell.create", "the format's options"]));
|
||||
for bad in ["same as paint.setFill", "{a, b", "{opacity? 0..1}", "{path | name+dataBase64}", "{xRelative?/yRelative?: anchor}"] {
|
||||
assert!(doc_keys(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every command's description reads as a params object, so none is left unchecked by accident.
|
||||
#[test]
|
||||
fn every_description_parses() {
|
||||
let bad: Vec<String> = command_specs().iter().filter_map(|c| doc_keys(c.params).err().map(|e| format!("{}: {e}", c.id))).collect();
|
||||
assert!(bad.is_empty(), "{} descriptions don't parse:\n{}", bad.len(), bad.join("\n"));
|
||||
let stale: Vec<_> = UNCHECKED.iter().filter(|id| find_command(id).is_none()).collect();
|
||||
assert!(stale.is_empty() && UNCHECKED.is_sorted(), "UNCHECKED: sorted ids of commands; not commands: {stale:?}");
|
||||
}
|
||||
|
||||
fn refusal(id: &str, params: serde_json::Value) -> String {
|
||||
match check_params(id, ¶ms) {
|
||||
Err(EngineError::BadParams { cmd, msg }) if cmd == id => msg,
|
||||
other => panic!("{id} {params}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The examples of #776: both ran and did nothing (or the default) without a word.
|
||||
#[test]
|
||||
fn the_issue_examples_are_refused() {
|
||||
// (The keys are checked in order: `end` comes before `start`.)
|
||||
assert_eq!(refusal("text.select", json!({"story": 1, "start": 0, "end": 5})), "unknown parameter `end`; the command takes: story, anchor, focus");
|
||||
assert_eq!(refusal("text.select", json!({"story": 1, "start": 0})), "unknown parameter `start`; the command takes: story, anchor, focus");
|
||||
assert_eq!(refusal("style.list", json!({"bogus": 1})), "unknown parameter `bogus`; the command takes no parameters");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_key_is_named_with_the_nearest_accepted_one() {
|
||||
assert_eq!(refusal("file.new", json!({"widht": 300})), "unknown parameter `widht`; did you mean `width`?");
|
||||
assert_eq!(refusal("file.new", json!({"Width": 300})), "unknown parameter `Width`; did you mean `width`?");
|
||||
assert_eq!(
|
||||
refusal("line.create", json!({"a": [0, 0], "b": [5, 5], "colour": "red"})),
|
||||
"unknown parameter `colour`; the command takes: spread, a, b"
|
||||
);
|
||||
for (id, ok) in [
|
||||
("file.new", json!({"width": 300, "title": "a", "margins": {"anything": 1}})),
|
||||
("file.new", json!({})),
|
||||
("file.new", json!(null)),
|
||||
// A command that acts on `ids` takes one object as `id` too.
|
||||
("transform.move", json!({"dx": 1, "dy": 1, "id": 3})),
|
||||
// Keys listed by reference.
|
||||
("style.cell.edit", json!({"name": "a", "fill": "Paper"})),
|
||||
// On UNCHECKED: the attributes may be the params object itself.
|
||||
("type.char", json!({"size": 12})),
|
||||
// Not a command: running it says so.
|
||||
("no.such", json!({"x": 1})),
|
||||
] {
|
||||
assert!(check_params(id, &ok).is_ok(), "{id} {ok}");
|
||||
}
|
||||
assert!(check_keys("x", "{a, …same as file.new}", &json!({"a": 1, "width": 2})).is_ok());
|
||||
assert!(check_keys("x", "{a, …same as file.new}", &json!({"b": 1})).is_err());
|
||||
// A `…` naming no command stands for any key.
|
||||
assert!(check_keys("x", "{a, …the format's options}", &json!({"b": 1})).is_ok());
|
||||
}
|
||||
|
||||
/// [`Session::execute`] itself takes what it is given (old journals replay); only callers from
|
||||
/// outside (control channel, MCP, CLI) are checked. In this crate's tests it checks too, as the
|
||||
/// audit of the descriptions, so this goes through a journal entry instead.
|
||||
#[test]
|
||||
fn journal_entries_pass_the_check() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Hello world"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"size": 24}})).unwrap();
|
||||
s.execute("transform.move", &json!({"dx": 10, "dy": 0, "ids": [f["id"]]})).unwrap();
|
||||
assert!(s.journal.len() >= 4);
|
||||
for (id, params) in &s.journal {
|
||||
assert!(check_params(id, params).is_ok(), "{id} {params}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! #776: callers from outside are checked with `check_params`; `Session::execute` itself takes
|
||||
//! what it is given, so a journal written by an older version still replays.
|
||||
|
||||
use designcraft_engine::{Session, check_params};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn execute_replays_what_the_check_refuses() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let params = json!({"bogus": 1});
|
||||
assert!(check_params("style.list", ¶ms).is_err());
|
||||
assert!(s.execute("style.list", ¶ms).is_ok());
|
||||
}
|
||||
@@ -95,6 +95,8 @@ impl Headless {
|
||||
}
|
||||
|
||||
fn exec(&mut self, id: &str, params: &Value) -> Result<Value, String> {
|
||||
// A key the command's description doesn't name is an error, not a default (#776).
|
||||
designcraft_engine::check_params(id, params).map_err(|e| e.to_string())?;
|
||||
let r = self.session.execute(id, params).map_err(|e| {
|
||||
let ui_only = ["app.", "view.", "window."].iter().any(|p| id.starts_with(p));
|
||||
if ui_only && designcraft_engine::find_command(id).is_none() { format!("`{id}` is a UI command; {NEEDS_APP}") } else { e.to_string() }
|
||||
|
||||
@@ -149,6 +149,7 @@ impl Server {
|
||||
"tools/call" => {
|
||||
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
|
||||
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
|
||||
check_arguments(name, &args).map_err(|m| (INVALID_PARAMS, m))?;
|
||||
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
|
||||
}
|
||||
"resources/list" => Ok(json!({"resources": [
|
||||
@@ -170,3 +171,41 @@ impl Server {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arguments a tool doesn't take, and command params a command doesn't take, are invalid params
|
||||
/// (#776): a misspelt key is refused instead of being ignored. `execute` and `batch` are checked
|
||||
/// against the params descriptions of the commands they run, every step before the first runs.
|
||||
fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
|
||||
let Some(arguments) = args.as_object() else { return Ok(()) };
|
||||
if let Some(def) = tool_definitions().into_iter().find(|t| t.get("name").and_then(Value::as_str) == Some(name))
|
||||
&& let Some(properties) = def.get("inputSchema").and_then(|s| s.get("properties")).and_then(Value::as_object)
|
||||
&& let Some(key) = arguments.keys().find(|k| !properties.contains_key(*k))
|
||||
{
|
||||
return Err(format!("unknown argument `{key}` for `{name}`"));
|
||||
}
|
||||
let command = |step: &Value| -> Result<(), String> {
|
||||
match (step.get("command").and_then(Value::as_str), step.get("params")) {
|
||||
(Some(id), Some(params)) => designcraft_engine::check_params(id, params).map_err(|e| e.to_string()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
};
|
||||
match name {
|
||||
"execute" => command(args),
|
||||
"batch" => {
|
||||
let steps = match (arguments.get("commands").and_then(Value::as_array), arguments.get("script").and_then(Value::as_str)) {
|
||||
(Some(steps), _) => steps.clone(),
|
||||
// A script that doesn't parse is the tool's to report.
|
||||
(None, Some(text)) => match designcraft_engine::script::parse(text) {
|
||||
Ok(steps) => steps.into_iter().map(|s| json!({"command": s.command, "params": s.params})).collect(),
|
||||
Err(_) => return Ok(()),
|
||||
},
|
||||
(None, None) => return Ok(()),
|
||||
};
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
command(step).map_err(|e| format!("step {i}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,3 +500,41 @@ fn explicit_typing_rejects_stale_selection_and_closed_document() {
|
||||
b.session.execute("file.close", &json!({})).unwrap();
|
||||
assert!(b.call("ui.text", json!({"text":"X"})).is_err());
|
||||
}
|
||||
|
||||
/// #776: a key a command (or a tool) doesn't take is an invalid-params error, not ignored.
|
||||
#[test]
|
||||
fn unknown_params_are_invalid_params() {
|
||||
let mut s = server();
|
||||
let code_and_message = |v: &Value| (v["error"]["code"].as_i64(), v["error"]["message"].as_str().unwrap_or("").to_string());
|
||||
let f = ok(&mut s, "execute", json!({"command": "frame.create", "params": {"rect": [36, 36, 300, 200], "content": "text", "text": "Hello"}}));
|
||||
let v = rpc(
|
||||
&mut s,
|
||||
1,
|
||||
"tools/call",
|
||||
json!({"name": "execute", "arguments": {"command": "text.select", "params": {"story": f["story"], "start": 0, "end": 5}}}),
|
||||
);
|
||||
let (code, msg) = code_and_message(&v);
|
||||
assert_eq!(code, Some(-32602), "{v}");
|
||||
assert!(msg.contains("unknown parameter `end`; the command takes: story, anchor, focus"), "{msg}");
|
||||
let v = rpc(&mut s, 2, "tools/call", json!({"name": "execute", "arguments": {"command": "style.list", "params": {"bogus": 1}}}));
|
||||
let (code, msg) = code_and_message(&v);
|
||||
assert_eq!(code, Some(-32602), "{v}");
|
||||
assert!(msg.contains("unknown parameter `bogus`; the command takes no parameters"), "{msg}");
|
||||
// A batch is checked step by step before any step runs: the frame isn't created.
|
||||
let before = ok(&mut s, "inspect_document", json!({}));
|
||||
for args in [
|
||||
json!({"commands": [{"command": "frame.create", "params": {"rect": [0, 0, 50, 50]}}, {"command": "file.new", "params": {"widht": 300}}]}),
|
||||
json!({"script": "frame.create {\"rect\": [0, 0, 50, 50]}\nfile.new {\"widht\": 300}"}),
|
||||
] {
|
||||
let v = rpc(&mut s, 3, "tools/call", json!({"name": "batch", "arguments": args}));
|
||||
let (code, msg) = code_and_message(&v);
|
||||
assert_eq!(code, Some(-32602), "{v}");
|
||||
assert!(msg.contains("step 1") && msg.contains("did you mean `width`?"), "{msg}");
|
||||
}
|
||||
assert_eq!(ok(&mut s, "inspect_document", json!({})), before);
|
||||
// An argument the tool doesn't take.
|
||||
let v = rpc(&mut s, 4, "tools/call", json!({"name": "execute", "arguments": {"command": "style.list", "parms": {}}}));
|
||||
assert_eq!(code_and_message(&v), (Some(-32602), "unknown argument `parms` for `execute`".to_string()));
|
||||
// The right keys still run.
|
||||
ok(&mut s, "execute", json!({"command": "text.select", "params": {"story": f["story"], "anchor": 0, "focus": 5}}));
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ impl PenTool {
|
||||
match self.path {
|
||||
None if self.anchors.len() >= 2 => {
|
||||
let anchors: Vec<Value> = self.anchors.iter().map(anchor_json).collect();
|
||||
vec![Action::Exec("path.create".into(), json!({"spread": spread_json(sr), "anchors": anchors, "closed": false, "notifyPen": true}))]
|
||||
vec![Action::Exec("path.create".into(), json!({"spread": spread_json(sr), "anchors": anchors, "closed": false}))]
|
||||
}
|
||||
Some(id) => {
|
||||
let mut p = json!({"id": id, "anchor": anchor_json(last)});
|
||||
|
||||
@@ -114,6 +114,10 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
||||
let params = p.get("params").cloned().unwrap_or(json!({}));
|
||||
let params = if params.is_null() { json!({}) } else { params };
|
||||
// A key the command's description doesn't name is an error, not a default (#776).
|
||||
if let Err(e) = designcraft_engine::check_params(id, ¶ms) {
|
||||
return err(e.to_string());
|
||||
}
|
||||
wrap(app.run(id, params))
|
||||
}
|
||||
"engine.commands" => ok(all_commands(app)),
|
||||
|
||||
@@ -4312,7 +4312,8 @@ mod tests {
|
||||
assert_eq!(page.margins.top, 10.0);
|
||||
assert_eq!(page.margins.outside, 40.0);
|
||||
}
|
||||
assert!(doc.spreads.iter().all(|s| s.items.is_empty()), "undocumented fields stay excluded");
|
||||
// `primaryTextFrame` is documented since #776, so the dialog passes it on (`status` isn't).
|
||||
assert!(doc.spreads.iter().any(|s| !s.items.is_empty()), "documented fields reach the command");
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser
|
||||
|
||||
| Method | Params | What it does |
|
||||
|---|---|---|
|
||||
| `engine.execute` / `ui.menu.invoke` | `{command, params}` | Run any engine or UI command (see `engine.commands`). `ui.menu.invoke` without `params` acts like choosing the menu item: a command labelled "…" that takes parameters opens its dialog |
|
||||
| `engine.execute` / `ui.menu.invoke` | `{command, params}` | Run any engine or UI command (see `engine.commands`). `ui.menu.invoke` without `params` acts like choosing the menu item: a command labelled "…" that takes parameters opens its dialog. A key the command's params description doesn't name is an error (`unknown parameter …`), not ignored |
|
||||
| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled |
|
||||
| `document.inspect` | — | Pages, spreads, items, stories (overset), styles, swatches, selection |
|
||||
| `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, perf |
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ and its second character starts at 3. An offset inside a character snaps back to
|
||||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `list_commands` | Every command (id, label, menu path, shortcut, params, enabled), with `filter` / `enabledOnly` |
|
||||
| `execute` | Run any command: `{command, params}` |
|
||||
| `execute` | Run any command: `{command, params}`. A params key the command doesn't take is a JSON-RPC `-32602` (invalid params) error naming it, as is an argument a tool doesn't take; `batch` checks every step before running the first |
|
||||
| `batch` | Run `commands: [{command, params}]` (or `script` text, one `command.id {json}` per line) in order, stopping at the first error (reports `failedIndex`); `"$N.path"` parameters use earlier results ([agents.md](agents.md)) |
|
||||
| `inspect_document` | Pages, spreads, items (ids, bounds, fill, story), stories (overset), layers, styles, swatches, selection |
|
||||
| `get_story` | Text, frames, paragraphs, lines, overset of a story (`story` or text `frame`) |
|
||||
|
||||
Reference in New Issue
Block a user