From 97ea84d99a7f2a42cafb618fde802660ae5a2c9c Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 29 Sep 2026 13:01:31 +0100 Subject: [PATCH] feat(text): add text_audit spell-check and dry-run replacement simulation - Adds ext_audit operation under ocs_read, control bridge, and headless server - Implements pairs / dry_run_pairs simulation returning diffs, coordinates, and unmatched queries - Implements check_terms suspect matching and dictionary vocabulary verification - Adds dry_run: true to ext_replace to preview modifications without mutating entities or pushing undo - Supports bounds, layers, handles, and scope filtering - Updates MCP tool definitions, schemas, and automation test suites --- docs/automation/mcp_smoke.py | 6 +- docs/automation/native.md | 2 +- src/app/automation.rs | 1 + src/app/control/mod.rs | 2 + src/app/control/p1_tests.rs | 109 +++++++++ src/app/control/text_ops.rs | 418 ++++++++++++++++++++++++++++++++--- src/mcp.rs | 5 +- 7 files changed, 510 insertions(+), 33 deletions(-) diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index dd1585f49..bbbff2d31 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -52,16 +52,18 @@ def legacy(server: Path) -> None: names = set(definitions) assert names == TOOLS, names execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"] - assert len(execute_request["oneOf"]) == 38 + assert len(execute_request["oneOf"]) == 39 op_enum = execute_request["properties"]["op"]["enum"] for shipped in ("entities_create", "entities_delete", "entities_transform", "block_define", "block_delete", "file_identity", "xdata_set", "view_focus", "wblock", "plot", "entities_copy_to", "group_create", "selection_set_save", "selection_set_load", "close", "sysvar", "layout_create", - "page_setup_set"): + "page_setup_set", "text_replace"): assert shipped in op_enum, shipped assert "xdata_get" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] + assert "text_search" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] + assert "text_audit" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] assert execute_request["properties"]["steps"]["maxItems"] == 64 assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10" assert "set_properties" in execute_request["properties"]["op"]["enum"] diff --git a/docs/automation/native.md b/docs/automation/native.md index d86cb6b99..ddfbf978f 100644 --- a/docs/automation/native.md +++ b/docs/automation/native.md @@ -33,7 +33,7 @@ Two request styles share the channel, told apart by the `protocol` field: | Style | Shape | Operations | |---|---|---| -| protocol 1 | `{"protocol":1,"request_id":"…","document_id":N,"op":"…"}` — the same requests and replies as `ocs_execute` / `ocs_read`, described in the README | `new`, `open`, `save`, `save_verified`, `activate`, `select`, `run`, `start`, `input`, `cancel`, `stop`, `action`, `property`, `set_properties`, `embed_image`, `undo`, `redo`; reads `state`, `capabilities`, `audit`, `query`, `entities`, `records`, `record_schema`, `layers`, `header`, `properties`, `measure`, `history`, `commands`, `events`, `operation` | +| protocol 1 | `{"protocol":1,"request_id":"…","document_id":N,"op":"…"}` — the same requests and replies as `ocs_execute` / `ocs_read`, described in the README | `new`, `open`, `save`, `save_verified`, `activate`, `select`, `run`, `start`, `input`, `cancel`, `stop`, `action`, `property`, `set_properties`, `embed_image`, `undo`, `redo`, `text_replace`; reads `state`, `capabilities`, `audit`, `text_audit`, `text_search`, `query`, `entities`, `records`, `record_schema`, `layers`, `header`, `properties`, `measure`, `history`, `commands`, `events`, `operation` | | legacy | `{"op":"…"}` without `protocol` | `new`, `open`, `run`, `entities`, `audit`, `query`, `records`, `record_schema`, `capabilities`, `layers`, `header`, `select`, `save`, `undo`, `redo` | The two styles differ in how a waiting command is treated. A protocol-1 `run` diff --git a/src/app/automation.rs b/src/app/automation.rs index 94d878680..ed5bdaa58 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -680,6 +680,7 @@ impl OpenCADStudio { "audit" => self.document_audit(&req), "query" => self.entity_query(&req), "text_search" => self.automation_text_search(&req), + "text_audit" => self.automation_text_audit(&req), "text_replace" => self.automation_text_replace(&req), "records" => self.record_query(&req), "record_schema" => self.record_schema(&req), diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 0d6780491..ce195c186 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -472,6 +472,7 @@ impl OpenCADStudio { | "get_selection" | "audit" | "text_search" + | "text_audit" ); if !query && !self.control.enabled { return ( @@ -731,6 +732,7 @@ impl OpenCADStudio { "xdata_get" => self.xdata_read(&req), "get_selection" => self.control_get_selection(), "text_search" => self.control_text_search(&req).unwrap_or_else(|e| e), + "text_audit" => self.control_text_audit(&req).unwrap_or_else(|e| e), "history" => { json!({"ok":true,"entries":self.command_line.history.iter().map(|e|json!({"kind":format!("{:?}",e.kind),"text":e.text})).collect::>()}) } diff --git a/src/app/control/p1_tests.rs b/src/app/control/p1_tests.rs index 445368372..44c5543ed 100644 --- a/src/app/control/p1_tests.rs +++ b/src/app/control/p1_tests.rs @@ -655,3 +655,112 @@ fn text_replace_op_batch_replaces_preserves_formatting_and_undoes() { assert_eq!(q_orig2["count"], 1); } +#[test] +fn text_audit_op_simulates_dry_run_checks_terms_and_dictionary() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"ta_c1","document_id":{doc},"entities":[ + {"type":"Text","position":[0,0,0],"value":"CIRTUITS 28-09-2026","layer":"0"}, + {"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic;CUVE EAU NON TRAITE}","layer":"0"}, + {"type":"Text","position":[20,0,0],"value":"POMPE A EAU","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Test text_audit with pairs, check_terms, and dictionary + let audit_res = app.automation_op( + r#"{ + "op":"text_audit", + "pairs":[ + {"find":"CIRTUITS","replace":"CIRCUITS"}, + {"find":"NON TRAITE","replace":"NON TRAITÉE"}, + {"find":"INEXISTANT_PAIR","replace":"REPLACEMENT"} + ], + "check_terms":["CIRTUITS", "NON TRAITE", "INEXISTANT_TERM"], + "dictionary":["EAU", "POMPE", "28-09-2026"] + }"#, + ); + assert_eq!(audit_res["ok"], true, "{}", audit_res["error"]); + + // Check summary + assert_eq!(audit_res["summary"]["pairs_total"], 3); + assert_eq!(audit_res["summary"]["pairs_unmatched"], 1); + assert_eq!(audit_res["summary"]["check_terms_total"], 3); + assert_eq!(audit_res["summary"]["check_terms_found"], 2); + + // Unmatched reporting + assert_eq!(audit_res["unmatched_pairs"], json!(["INEXISTANT_PAIR"])); + assert_eq!(audit_res["unmatched_check_terms"], json!(["INEXISTANT_TERM"])); + + // Simulated changes has 2 entries (Text and MText) + let changes = audit_res["simulated_changes"].as_array().unwrap(); + assert_eq!(changes.len(), 2); + assert_eq!(changes[0]["find"], "CIRTUITS"); + assert_eq!(changes[0]["replace"], "CIRCUITS"); + assert_eq!(changes[0]["replaced"], 1); + assert!(changes[1]["after"].as_str().unwrap().contains("NON TRAITÉE")); + + // Suspect matches + let suspects = audit_res["suspect_matches"].as_array().unwrap(); + assert_eq!(suspects.len(), 2); + + // Verify document was NOT mutated (pure read op) + let q = app.automation_op(r#"{"op":"text_search","find":"CIRTUITS"}"#); + assert_eq!(q["ok"], true); + assert_eq!(q["count"], 1); +} + +#[test] +fn text_replace_op_supports_dry_run_without_mutating_or_undo() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"tr_dry1","document_id":{doc},"entities":[ + {"type":"Text","position":[0,0,0],"value":"TEST BEFORE REPLACE","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Run text_replace with dry_run = true + let dry_res = mutate( + &mut app, + r#"{"protocol":1,"op":"text_replace","request_id":"tr_sim1","document_id":{doc},"find":"BEFORE","replace":"AFTER","dry_run":true}"#, + ); + assert_eq!(dry_res["ok"], true, "{}", dry_res["error"]); + assert_eq!(dry_res["result"]["dry_run"], true); + assert_eq!(dry_res["result"]["replaced"], 1); + assert_eq!(dry_res["result"]["entities_changed"], 1); + + // Verify the document was NOT modified + let q_before = app.automation_op(r#"{"op":"text_search","find":"TEST BEFORE REPLACE"}"#); + assert_eq!(q_before["ok"], true); + assert_eq!(q_before["count"], 1); + + let q_after = app.automation_op(r#"{"op":"text_search","find":"TEST AFTER REPLACE"}"#); + assert_eq!(q_after["ok"], true); + assert_eq!(q_after["count"], 0); + + // Now run with dry_run = false + let real_res = mutate( + &mut app, + r#"{"protocol":1,"op":"text_replace","request_id":"tr_real1","document_id":{doc},"find":"BEFORE","replace":"AFTER","dry_run":false}"#, + ); + assert_eq!(real_res["ok"], true, "{}", real_res["error"]); + assert_eq!(real_res["result"]["replaced"], 1); + + // Now it was modified + let q_after2 = app.automation_op(r#"{"op":"text_search","find":"TEST AFTER REPLACE"}"#); + assert_eq!(q_after2["ok"], true); + assert_eq!(q_after2["count"], 1); + + // Undo restores it + app.automation_op(r#"{"op":"undo"}"#); + let q_undone = app.automation_op(r#"{"op":"text_search","find":"TEST BEFORE REPLACE"}"#); + assert_eq!(q_undone["ok"], true); + assert_eq!(q_undone["count"], 1); +} + + diff --git a/src/app/control/text_ops.rs b/src/app/control/text_ops.rs index e0bf2f919..e331c45e4 100644 --- a/src/app/control/text_ops.rs +++ b/src/app/control/text_ops.rs @@ -758,7 +758,14 @@ impl OpenCADStudio { let limit = req["limit"].as_u64().unwrap_or(500).min(5000) as usize; let matcher = TextMatcher::new(find.to_string(), match_case, whole_word, ignore_accents); - let i = self.active_tab; + let i = if let Some(doc_id) = req.get("document_id").and_then(|v| v.as_u64()) { + self.tabs + .iter() + .position(|t| t.id == doc_id) + .ok_or_else(|| failure("document_not_found", "Specified document_id not found"))? + } else { + self.active_tab + }; let scene = &self.tabs[i].scene; let doc = &scene.document; @@ -870,13 +877,342 @@ impl OpenCADStudio { })) } + /// `text_audit` — comprehensive text quality check, spell-check, and replacement dry-run. + pub(crate) fn control_text_audit(&self, req: &Value) -> Result { + let i = if let Some(doc_id) = req.get("document_id").and_then(|v| v.as_u64()) { + self.tabs + .iter() + .position(|t| t.id == doc_id) + .ok_or_else(|| failure("document_not_found", "Specified document_id not found"))? + } else { + self.active_tab + }; + + let match_case = req["match_case"].as_bool().unwrap_or(false); + let whole_word = req["whole_word"].as_bool().unwrap_or(false); + let ignore_accents = req["ignore_accents"].as_bool().unwrap_or(!match_case); + let scope = req["scope"].as_str().unwrap_or("all"); + let layer_filter = req["layer"].as_str(); + let handles_filter = parse_handles_filter(req); + let types_filter = parse_types_filter(req); + let bounds_filter = parse_bounds_filter(req)?; + let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize; + + // Parse dry-run replacement pairs if supplied + let mut pairs: Vec<(String, String)> = Vec::new(); + let pairs_val = req.get("pairs").or_else(|| req.get("dry_run_pairs")); + if let Some(pair_array) = pairs_val.and_then(|v| v.as_array()) { + for item in pair_array { + let find = item["find"].as_str().unwrap_or(""); + let replace = item["replace"].as_str().unwrap_or(""); + if !find.is_empty() { + pairs.push((find.to_string(), replace.to_string())); + } + } + } + + // Parse check terms if supplied + let mut check_terms: Vec = Vec::new(); + if let Some(terms_array) = req.get("check_terms").and_then(|v| v.as_array()) { + for item in terms_array { + if let Some(t) = item.as_str() { + if !t.is_empty() { + check_terms.push(t.to_string()); + } + } + } + } + + // Parse dictionary if supplied + let mut dictionary: Vec = Vec::new(); + if let Some(dict_array) = req.get("dictionary").and_then(|v| v.as_array()) { + for item in dict_array { + if let Some(w) = item.as_str() { + if !w.is_empty() { + dictionary.push(w.to_string()); + } + } + } + } + + let pair_matchers: Vec<(TextMatcher, String)> = pairs + .iter() + .map(|(f, r)| { + ( + TextMatcher::new(f.clone(), match_case, whole_word, ignore_accents), + r.clone(), + ) + }) + .collect(); + + let check_matchers: Vec = check_terms + .iter() + .map(|t| TextMatcher::new(t.clone(), match_case, whole_word, ignore_accents)) + .collect(); + + let dict_matchers: Vec = dictionary + .iter() + .map(|w| TextMatcher::new(w.clone(), false, true, true)) + .collect(); + + let mut pair_hit_counts = vec![0usize; pair_matchers.len()]; + let mut check_hit_counts = vec![0usize; check_matchers.len()]; + + let scene = &self.tabs[i].scene; + let doc = &scene.document; + + let mut entities_scanned = 0usize; + let mut entities_matched = 0usize; + let mut total_occurrences = 0usize; + let mut simulated_changes = Vec::new(); + let mut suspect_matches = Vec::new(); + let mut unrecognized_words = Vec::new(); + + for entity in doc.entities() { + let common = entity.common(); + let handle = common.handle; + + let in_active = scene.entity_belongs_to_active_space(handle); + match scope { + "active_space" | "model_space" if !in_active => continue, + "blocks" if in_active => continue, + _ => {} + } + + if let Some(l) = layer_filter { + if !common.layer.eq_ignore_ascii_case(l) { + continue; + } + } + + if let Some(ref h_list) = handles_filter { + if !h_list.contains(&handle) { + continue; + } + } + + if let Some(ref t_list) = types_filter { + if !t_list + .iter() + .any(|t| crate::app::automation::entity_type_matches(entity, t)) + { + continue; + } + } + + if let Some(b) = bounds_filter { + let (min, max) = crate::scene::convert::tess::entity_bounds_in(doc, entity); + if max[0] < b[0] || max[1] < b[1] || min[0] > b[2] || min[1] > b[3] { + continue; + } + } + + let Some((raw_val, plain_text, pos)) = entity_text_info(entity, doc) else { + continue; + }; + + entities_scanned += 1; + let mut entity_had_match = false; + + // 1. Dry run pairs simulation + if !pair_matchers.is_empty() { + match entity { + EntityType::MText(_) => { + let mut cur_raw = raw_val.clone(); + for (idx, (matcher, replacement)) in pair_matchers.iter().enumerate() { + let (new_raw, count) = + replace_mtext_safe(&cur_raw, matcher, replacement, true); + if count > 0 { + pair_hit_counts[idx] += count; + total_occurrences += count; + entity_had_match = true; + if simulated_changes.len() < limit { + simulated_changes.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "MText", + "layer": common.layer, + "position": pos, + "find": pairs[idx].0, + "replace": pairs[idx].1, + "before": cur_raw, + "after": new_raw.clone(), + "replaced": count, + })); + } + cur_raw = new_raw; + } + } + } + _ => { + let mut cur_text = plain_text.clone(); + for (idx, (matcher, replacement)) in pair_matchers.iter().enumerate() { + let (new_text, count) = + matcher.replace_all_in(&cur_text, replacement, true); + if count > 0 { + pair_hit_counts[idx] += count; + total_occurrences += count; + entity_had_match = true; + if simulated_changes.len() < limit { + simulated_changes.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": crate::entities::names::ui_name(entity), + "layer": common.layer, + "position": pos, + "find": pairs[idx].0, + "replace": pairs[idx].1, + "before": cur_text, + "after": new_text.clone(), + "replaced": count, + })); + } + cur_text = new_text; + } + } + } + } + } + + // 2. Check terms + for (idx, matcher) in check_matchers.iter().enumerate() { + if matcher.matches_text_or_raw(&plain_text, &raw_val) { + check_hit_counts[idx] += 1; + entity_had_match = true; + if suspect_matches.len() < limit { + suspect_matches.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": crate::entities::names::ui_name(entity), + "layer": common.layer, + "position": pos, + "term": check_terms[idx], + "plain_text": plain_text, + })); + } + } + } + + // 3. Dictionary check + if !dict_matchers.is_empty() { + for word in plain_text.split(|c: char| !c.is_alphabetic()) { + let w = word.trim(); + if w.len() >= 3 && !dict_matchers.iter().any(|dm| dm.matches(w)) { + if unrecognized_words.len() < limit + && !unrecognized_words + .iter() + .any(|item: &Value| item["word"].as_str() == Some(w)) + { + unrecognized_words.push(json!({ + "word": w, + "handle": format!("{:X}", handle.value()), + "position": pos, + })); + } + } + } + } + + // Check Insert attributes + if let EntityType::Insert(insert) = entity { + for attr in &insert.attributes { + let raw = attr.get_value(); + let plain = decode_dxf_escapes(raw); + + for (idx, (matcher, replacement)) in pair_matchers.iter().enumerate() { + let (new_text, count) = + matcher.replace_all_in(&plain, replacement, true); + if count > 0 { + pair_hit_counts[idx] += count; + total_occurrences += count; + entity_had_match = true; + if simulated_changes.len() < limit { + simulated_changes.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "Insert", + "attribute_tag": attr.tag, + "layer": common.layer, + "position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z], + "find": pairs[idx].0, + "replace": pairs[idx].1, + "before": plain.clone(), + "after": new_text, + "replaced": count, + })); + } + } + } + + for (idx, matcher) in check_matchers.iter().enumerate() { + if matcher.matches_text_or_raw(&plain, raw) { + check_hit_counts[idx] += 1; + entity_had_match = true; + if suspect_matches.len() < limit { + suspect_matches.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "Insert", + "attribute_tag": attr.tag, + "layer": common.layer, + "position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z], + "term": check_terms[idx], + "plain_text": plain, + })); + } + } + } + } + } + + if entity_had_match { + entities_matched += 1; + } + } + + // Unmatched pairs / queries + let unmatched_pairs: Vec = pair_hit_counts + .iter() + .enumerate() + .filter(|(_, &count)| count == 0) + .map(|(idx, _)| pairs[idx].0.clone()) + .collect(); + + let unmatched_check_terms: Vec = check_hit_counts + .iter() + .enumerate() + .filter(|(_, &count)| count == 0) + .map(|(idx, _)| check_terms[idx].clone()) + .collect(); + + Ok(json!({ + "ok": true, + "summary": { + "entities_scanned": entities_scanned, + "entities_matched": entities_matched, + "occurrences_matched": total_occurrences, + "pairs_total": pairs.len(), + "pairs_unmatched": unmatched_pairs.len(), + "check_terms_total": check_terms.len(), + "check_terms_found": check_terms.len() - unmatched_check_terms.len(), + }, + "unmatched_pairs": unmatched_pairs, + "unmatched_check_terms": unmatched_check_terms, + "simulated_changes": simulated_changes, + "suspect_matches": suspect_matches, + "unrecognized_words": unrecognized_words, + })) + } + /// Execute text replace across candidates with single-transaction undo support. pub(crate) fn execute_text_replace( &mut self, req: &Value, push_undo: bool, ) -> Result { - let i = self.active_tab; + let i = if let Some(doc_id) = req.get("document_id").and_then(|v| v.as_u64()) { + self.tabs + .iter() + .position(|t| t.id == doc_id) + .ok_or_else(|| failure("document_not_found", "Specified document_id not found"))? + } else { + self.active_tab + }; // Parse search/replace pairs let mut pairs: Vec<(String, String)> = Vec::new(); @@ -905,6 +1241,7 @@ impl OpenCADStudio { let match_case = req["match_case"].as_bool().unwrap_or(false); let whole_word = req["whole_word"].as_bool().unwrap_or(false); let ignore_accents = req["ignore_accents"].as_bool().unwrap_or(!match_case); + let dry_run = req["dry_run"].as_bool().unwrap_or(false); let replace_all = req["replace_all"].as_bool().unwrap_or(true); let scope = req["scope"].as_str().unwrap_or("all"); let layer_filter = req["layer"].as_str(); @@ -917,7 +1254,7 @@ impl OpenCADStudio { .map(|(f, r)| (TextMatcher::new(f, match_case, whole_word, ignore_accents), r)) .collect(); - if push_undo { + if push_undo && !dry_run { self.push_undo_snapshot(i, "TEXT REPLACE"); } @@ -998,9 +1335,11 @@ impl OpenCADStudio { if before_val.is_none() { before_val = Some(text.value.clone()); } - text.value = new_val; + if !dry_run { + text.value = new_val.clone(); + } entity_replaced += count; - after_val = Some(text.value.clone()); + after_val = Some(new_val); } else { // Check if matching decoded DXF escapes let decoded = decode_dxf_escapes(&text.value); @@ -1011,9 +1350,11 @@ impl OpenCADStudio { if before_val.is_none() { before_val = Some(text.value.clone()); } - text.value = new_val; + if !dry_run { + text.value = new_val.clone(); + } entity_replaced += c; - after_val = Some(text.value.clone()); + after_val = Some(new_val); } } } @@ -1027,9 +1368,11 @@ impl OpenCADStudio { if before_val.is_none() { before_val = Some(mtext.value.clone()); } - mtext.value = new_val; + if !dry_run { + mtext.value = new_val.clone(); + } entity_replaced += count; - after_val = Some(mtext.value.clone()); + after_val = Some(new_val); } } } @@ -1041,9 +1384,11 @@ impl OpenCADStudio { if before_val.is_none() { before_val = Some(ad.default_value.clone()); } - ad.default_value = new_val; + if !dry_run { + ad.default_value = new_val.clone(); + } entity_replaced += count; - after_val = Some(ad.default_value.clone()); + after_val = Some(new_val); } } } @@ -1055,9 +1400,11 @@ impl OpenCADStudio { if before_val.is_none() { before_val = Some(ae.get_value().to_string()); } - ae.set_value(new_val); + if !dry_run { + ae.set_value(new_val.clone()); + } entity_replaced += count; - after_val = Some(ae.get_value().to_string()); + after_val = Some(new_val); } } } @@ -1071,9 +1418,11 @@ impl OpenCADStudio { if before_val.is_none() { before_val = Some(base.text.clone()); } - base.text = new_val; + if !dry_run { + base.text = new_val.clone(); + } entity_replaced += count; - after_val = Some(base.text.clone()); + after_val = Some(new_val); } } } @@ -1085,7 +1434,9 @@ impl OpenCADStudio { matcher.replace_all_in(attr.get_value(), replacement, replace_all); if count > 0 { let old_val = attr.get_value().to_string(); - attr.set_value(new_val.clone()); + if !dry_run { + attr.set_value(new_val.clone()); + } total_replaced += count; changes_log.push(json!({ "handle": format!("{:X}", handle.value()), @@ -1121,33 +1472,37 @@ impl OpenCADStudio { } if total_replaced == 0 { - if push_undo { + if push_undo && !dry_run { self.discard_last_undo_entry(i); } return Ok(json!({ "ok": true, + "dry_run": dry_run, "replaced": 0, "entities_changed": 0, "changes": [], })); } - // Refresh scene caches and dirty flags - self.invalidate_property_targets(i, &changed_handles); - self.tabs[i].scene.bump_geometry(); - self.tabs[i].dirty = true; - self.refresh_properties(); + if !dry_run { + // Refresh scene caches and dirty flags + self.invalidate_property_targets(i, &changed_handles); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.refresh_properties(); - self.command_line.push_output( - format!( - "TEXT REPLACE: replaced {total_replaced} occurrence(s) in {} object(s).", - changed_handles.len() - ) - .as_str(), - ); + self.command_line.push_output( + format!( + "TEXT REPLACE: replaced {total_replaced} occurrence(s) in {} object(s).", + changed_handles.len() + ) + .as_str(), + ); + } Ok(json!({ "ok": true, + "dry_run": dry_run, "replaced": total_replaced, "entities_changed": changed_handles.len(), "changes": changes_log, @@ -1166,6 +1521,11 @@ impl OpenCADStudio { self.control_text_search(req).unwrap_or_else(|e| e) } + /// Headless automation text audit. + pub(crate) fn automation_text_audit(&self, req: &Value) -> Value { + self.control_text_audit(req).unwrap_or_else(|e| e) + } + /// Headless automation text replace. pub(crate) fn automation_text_replace(&mut self, req: &Value) -> Value { self.execute_text_replace(req, true).unwrap_or_else(|e| e) diff --git a/src/mcp.rs b/src/mcp.rs index ef34d0380..4540ffaba 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -43,6 +43,7 @@ const READ_OPS: &[&str] = &[ "xdata_get", "audit", "text_search", + "text_audit", ]; const EXECUTE_OPS: &[&str] = &[ "new", @@ -1064,6 +1065,7 @@ fn execute_request_schema() -> Value { "match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search/replace."}, "whole_word":{"type":"boolean","default":false,"description":"Match only whole words (bounded by non-alphanumeric characters)."}, "ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."}, + "dry_run":{"type":"boolean","default":false,"description":"When true, simulates replacement without mutating entities or pushing undo."}, "replace_all":{"type":"boolean","default":true,"description":"Replace all occurrences within matching entities (default true)."}, "scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search/replace scope."}, "path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image, target DWG/DXF for wblock, target PDF for plot; save_verified requires .dwg or .dxf."}, @@ -1223,7 +1225,7 @@ fn tool_definitions() -> Value { { "name":"ocs_read", "description":"Discover capabilities and record schemas, or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", - "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, + "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"check_terms":{"type":"array","items":{"type":"string"},"description":"List of terms or suspect misspellings to flag in text_audit."},"dictionary":{"type":"array","items":{"type":"string"},"description":"Known valid words for text_audit dictionary check."},"pairs":{"type":"array","description":"Find/replace pairs for dry-run simulation in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"dry_run_pairs":{"type":"array","description":"Alias for pairs in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, "outputSchema":read_output_schema(), "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, @@ -1595,6 +1597,7 @@ mod tests { assert!(READ_OPS.contains(&"record_schema")); assert!(READ_OPS.contains(&"audit")); assert!(READ_OPS.contains(&"text_search")); + assert!(READ_OPS.contains(&"text_audit")); assert!(EXECUTE_OPS.contains(&"set_properties")); assert!(EXECUTE_OPS.contains(&"save_verified")); assert!(EXECUTE_OPS.contains(&"text_replace"));