← Commit history
rust/Cargo.lock+3−3
@@ -319,7 +319,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.91"+version = "1.0.92" dependencies = [  "adom-eda-checks",  "kicad-core",@@ -331,7 +331,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.91"+version = "1.0.92" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.91"+version = "1.0.92" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.91"+version = "1.0.92" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_sch_edit.rs+67−3
@@ -1068,16 +1068,67 @@ fn connect_to_bus(ctx: &mut Ctx) -> Result<Done, Value> { // Batch // --------------------------------------------------------------------------- +/// The batch's ops in KiCad's own names and shapes: an op may name the standard's schematic verb (sch_place_part,+/// sch_connect, sch_edit, ... with the standard's arguments, the standard's sch_batch #29) and is turned into KiCad's+/// the way a direct call is (catalog alias, then std_shapes); one standard op can become several KiCad ops.+fn native_ops(ops: &[Value]) -> Result<Vec<Value>, Value> {+    let mut out = Vec::new();+    for (i, opv) in ops.iter().enumerate() {+        let name = opv.get("op").and_then(Value::as_str).map(str::trim).ok_or_else(|| fail("bad_arg", format!("ops[{i}] has no op"), "Every op names its operation: {\"op\": \"sch_place_part\", ...}."))?;+        // the four standard schematic verbs KiCad answers under its own names (equivalents/kicad.json): the op runs as+        // KiCad's verb, chosen by the arguments where one standard verb is several KiCad ones, with KiCad's arguments+        let has = |k: &str| opv.get(k).is_some_and(|v| !v.is_null());+        let mapped = match name.trim_start_matches("kicad_") {+            "sch_place_part" => Some("sch_place_symbol"),+            "sch_swap_part" => Some("sch_replace_symbol"),+            "sch_connect" if has("net") && (has("pin") || has("pins") || has("reference")) => Some("sch_connect_to_net"),+            "sch_connect" if has("points") || has("from") => Some("sch_wire"),+            "sch_connect" if has("text") || has("label") => Some("sch_label"),+            "sch_connect" => Some("sch_no_connect"),+            "sch_edit" if opv.get("delete") == Some(&json!(true)) => Some("sch_delete"),+            "sch_edit" if has("rotation") || has("angle") => Some("sch_rotate"),+            "sch_edit" if has("to") || has("by") || has("at") => Some("sch_move"),+            "sch_edit" => Some("sch_set_property"),+            _ => None,+        };+        let name = mapped.unwrap_or(name);+        let called = if name.starts_with("kicad_") { name.to_string() } else { format!("kicad_{name}") };+        let full = crate::catalog::alias_target(&called).map(String::from).unwrap_or_else(|| called.clone());+        let mut args = opv.clone();+        if let Some(o) = args.as_object_mut() {+            o.remove("op");+        }+        match crate::std_shapes::to_native(&called, &full, &args) {+            Some(Err(refusal)) => return Err(refusal),+            Some(Ok(sh)) if sh.command == "kicad_sch_batch" => out.extend(sh.args["ops"].as_array().cloned().unwrap_or_default()),+            Some(Ok(sh)) => {+                let mut a = sh.args.clone();+                a["op"] = json!(sh.command.trim_start_matches("kicad_sch_").trim_start_matches("kicad_"));+                out.push(a);+            }+            None => {+                let mut a = opv.clone();+                a["op"] = json!(name);+                out.push(a);+            }+        }+    }+    Ok(out)+}+ fn batch(ctx: &mut Ctx) -> Result<Done, Value> {-    let ops = ctx.args.get("ops").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(|| fail("missing_arg", "ops is required", "Pass ops as a list of {\"op\": \"wire\", ...that verb's arguments}."))?.clone();+    let given = ctx.args.get("ops").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(|| fail("missing_arg", "ops is required", "Pass ops as a list of {\"op\": \"sch_place_part\" | \"place_symbol\", ...that verb's arguments}."))?.clone();+    let ops = native_ops(&given)?;     let mut results = Vec::new();     for (i, opv) in ops.iter().enumerate() {-        let name = opv.get("op").and_then(Value::as_str).map(|s| s.trim().trim_start_matches("kicad_sch_").to_string()).ok_or_else(|| fail("bad_arg", format!("ops[{i}] has no op"), "Every op names its operation: {\"op\": \"wire\", ...}."))?;+        let name = opv.get("op").and_then(Value::as_str).map(|s| s.trim().trim_start_matches("kicad_").trim_start_matches("sch_").to_string()).ok_or_else(|| fail("bad_arg", format!("ops[{i}] has no op"), "Every op names its operation: {\"op\": \"wire\", ...}."))?;         if name == "batch" {             return Err(fail("bad_arg", format!("ops[{i}] is a batch; batches do not nest"), "Flatten the ops into one list."));         }         let Some(f) = op_for(&name) else {-            return Err(fail("bad_arg", format!("ops[{i}]: unknown op {name}"), "Ops: place_symbol, wire, label, move, rotate, delete, set_property, no_connect, move_region, annotate, replace_symbol, update_from_library, reset_fields, delete_wire, split_wire, edit_label, junction, connect_to_net, connect_pins, text, bus, bus_entry, connect_to_bus."));+            let mut v = fail("bad_arg", format!("ops[{i}]: unknown op {name}"), "Ops: the standard's schematic verbs (sch_place_part, sch_connect, sch_edit, ...) or KiCad's: place_symbol, wire, label, move, rotate, delete, set_property, no_connect, move_region, annotate, replace_symbol, update_from_library, reset_fields, delete_wire, split_wire, edit_label, junction, connect_to_net, connect_pins, text, bus, bus_entry, connect_to_bus.");+            v["failedOp"] = json!(i);+            return Err(v);         };         let mut sub = Ctx { sch: &mut *ctx.sch, path: ctx.path, lib: ctx.lib, args: opv };         match f(&mut sub) {@@ -1318,6 +1369,19 @@ mod tests {         err("kicad_sch_connect_to_bus", &p, json!({"pins": ["U1.6"], "bus": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "members": ["X"], "entry": "up"}), "entry_off_bus");     } +    #[test]+    fn batch_takes_the_standards_op_names() {+        let p = temp_sheet("stdbatch");+        let v = call("kicad_sch_batch", &p, json!({"ops": [+            {"op": "sch_junction", "at": [101.6, 101.6]},+            {"op": "sch_place_part", "libId": "Device:R", "reference": "R77", "at": [127.0, 127.0]},+            {"op": "sch_text", "text": "standard names", "at": [50.8, 50.8]}]}));+        assert_eq!(v["success"], true, "{v}");+        assert_eq!(v["results"].as_array().map(|a| a.len()), Some(3), "{v}");+        let bad = call("kicad_sch_batch", &p, json!({"ops": [{"op": "sch_text", "text": "x", "at": [1, 1]}, {"op": "sch_no_such_verb"}]}));+        assert_eq!(bad["failedOp"], 1, "{bad}");+    }+     #[test]     fn batch_is_all_or_nothing() {         let p = temp_sheet("batch");
rust/crates/kicad-bridge/src/verbs_schematic.rs+6
@@ -240,6 +240,12 @@ pub(crate) fn edit(state: &mut State, args: &Value, op: Op) -> Value {             o.insert(k.clone(), val.clone());         }     }+    // the standard's sch_batch (#29) names the two ERC counts on their own+    if v.get("ops").is_some() && v.get("results").is_some() {+        v["ercBefore"] = v["erc"]["before"].clone();+        v["ercAfter"] = v["erc"]["after"].clone();+        v["mutated"] = json!(true);+    }     v } 
rust/crates/kicad-core/src/bridge_log.rs+3−1
@@ -135,7 +135,7 @@ pub fn log_tail(dir: Option<&Path>, args: &Value) -> Value {     let bridge_log = bridge_log_path().filter(|p| p.is_file()).map(|p| p.to_string_lossy().replace('\\', "/"));     let Some(p) = p.filter(|p| p.is_file()) else {         return json!({-            "success": true, "available": false, "lines": [], "nextOffset": 0,+            "success": true, "available": false, "lines": [], "nextOffset": 0, "entries": [], "next": 0,             "logDir": dir.map(|d| d.to_string_lossy().replace('\\', "/")),             "bridgeLogPath": bridge_log,             "_hint": NO_LOG_HINT,@@ -178,6 +178,8 @@ pub fn log_tail(dir: Option<&Path>, args: &Value) -> Value {         "logPath": p.to_string_lossy().replace('\\', "/"),         "sinceOffset": since, "nextOffset": size, "truncated": truncated,         "lineCount": lines.len(), "levels": counts, "lines": lines,+        // the standard's log_tail (#29): entries and next, the same lines and offset+        "entries": lines, "next": size,         "bridgeLogPath": bridge_log,         "_hint": "Carry nextOffset into the next call and you get ONLY what happened since: tail, act, tail again, and the second read is your action's consequences. errorsOnly:true filters to error/warning.",     })
skills/kicad-bridge-test/run_verb_tests.py+8−3
@@ -76,6 +76,8 @@ def ok(r: dict) -> bool: # Checks beyond ok(): named lambdas in CHECKS below.  CHECKS = {+    # 1.0.92 (standard log_tail #29): entries and next beside lines and nextOffset+    "log_entries": lambda r: ok(r) and isinstance(r.get("entries"), list) and "next" in r and r.get("next") == r.get("nextOffset"),     "ok": ok,     "verbs>=47": lambda r: ok(r) and len(r.get("verbs") or []) >= 47,     "editors_listed": lambda r: ok(r) and "summary" in r,@@ -148,7 +150,7 @@ def matrix(w: dict) -> dict:         "list_footprints":   dict(phase=1, args={"library": "Resistor_SMD"}, timeout=90),         "list_symbols":      dict(phase=1, args={"libraryName": "Device"}, timeout=90,                                   note="the #80 regression: a STOCK library must resolve to KiCad's share dir"),-        "log_tail":          dict(phase=1, args={}),+        "log_tail":          dict(phase=1, args={}, check="log_entries"),         "progress":          dict(phase=1, args={}),         "verb_times":        dict(phase=1, args={}),         "errors":            dict(phase=1, args={}, note="win32 text read of KiCad message boxes; none open is fine"),@@ -1215,8 +1217,11 @@ def main():         r = K("sch_update_from_library", {"filePath": sch}); rec("sch_update_from_library", r, ok(r))         r = K("sch_fix_connectivity", {"filePath": sch, "dryRun": True}); rec("sch_fix_connectivity", r, ok(r) and r.get("mutated") is not True)         r = K("sch_check", {"filePath": sch}); rec("sch_check", r, ok(r))-        r = K("sch_batch", {"filePath": sch, "ops": [{"op": "text", "text": "batch 1", "at": [30.48, 35.56]}, {"op": "text", "text": "batch 2", "at": [30.48, 40.64]}]})-        rec("sch_batch", r, ok(r))+        # 1.0.92 (standard sch_batch #29): KiCad's op names and the standard's together; a failing op is named and nothing is written+        r = K("sch_batch", {"filePath": sch, "ops": [{"op": "text", "text": "batch 1", "at": [30.48, 35.56]}, {"op": "sch_text", "text": "batch 2", "at": [30.48, 40.64]},+                                                     {"op": "sch_junction", "at": [30.48, 45.72]}]})+        rb = K("sch_batch", {"filePath": sch, "ops": [{"op": "sch_text", "text": "never", "at": [30.48, 50.8]}, {"op": "sch_no_such_verb"}]})+        rec("sch_batch", {"ok": r, "bad": rb}, ok(r) and len(r.get("results") or []) == 3 and "ercBefore" in r and rb.get("failedOp") == 1)         r = K("sch_sheet", {"filePath": sch, "action": "add", "name": "Child", "file": "child.kicad_sch", "at": [127, 30.48], "size": [25.4, 20.32], "projectPath": pro})         rec("sch_sheet", r, ok(r))         r = K("sch_sheet_pins", {"filePath": sch, "sheet": "Child", "action": "add", "name": "VIN", "shape": "input", "side": "left"})