From 88169c1d7a2e8f33c02fb8d0c42a9d1679f8f76a Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 29 Sep 2026 10:40:24 +0100 Subject: [PATCH] feat(mcp): add text_search and text_replace with multiline MText and accent-insensitive matching - Add text_search (read) and text_replace (mutating) operations to control bridge, automation, and MCP - Support single and batch find/replace pairs under atomic undo transactions - Safely parse and preserve MText formatting tags, inline fonts, colors, and margins - Add flexible multiline MText matching and replacement across paragraph breaks (\n <-> \P) - Add accent/diacritic-insensitive matching across Unicode and ASCII character sets - Support Windows-1252 / ISO-8859-1 code decoding (%%nnn), DXF Unicode escapes (\U+XXXX), and special character codes - Expose ignore_accents parameter across ocs_read and ocs_execute MCP tool schemas --- src/app/automation.rs | 2 + src/app/control/mod.rs | 4 + src/app/control/p1_tests.rs | 89 +++ src/app/control/text_ops.rs | 1270 +++++++++++++++++++++++++++++++++++ src/lib.rs | 2 +- src/mcp.rs | 29 +- 6 files changed, 1394 insertions(+), 2 deletions(-) create mode 100644 src/app/control/text_ops.rs diff --git a/src/app/automation.rs b/src/app/automation.rs index d822e9465..94d878680 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -679,6 +679,8 @@ impl OpenCADStudio { "entities" => self.entity_summary(), "audit" => self.document_audit(&req), "query" => self.entity_query(&req), + "text_search" => self.automation_text_search(&req), + "text_replace" => self.automation_text_replace(&req), "records" => self.record_query(&req), "record_schema" => self.record_schema(&req), "capabilities" => self.record_capabilities(), diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index cd08c9916..0d6780491 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -471,6 +471,7 @@ impl OpenCADStudio { | "xdata_get" | "get_selection" | "audit" + | "text_search" ); if !query && !self.control.enabled { return ( @@ -729,6 +730,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), "history" => { json!({"ok":true,"entries":self.command_line.history.iter().map(|e|json!({"kind":format!("{:?}",e.kind),"text":e.text})).collect::>()}) } @@ -975,6 +977,7 @@ impl OpenCADStudio { "entities_delete" => self.control_entities_delete(req)?, "entities_transform" => self.control_entities_transform(req)?, "entities_copy_to" => self.control_entities_copy_to(req)?, + "text_replace" => self.control_text_replace(req)?, "xdata_set" => self.control_xdata_set(req)?, "block_define" => self.control_block_define(req)?, "block_delete" => self.control_block_delete(req)?, @@ -1340,6 +1343,7 @@ pub(crate) mod http_bridge; mod interactive; mod sheets; pub(crate) use sheets::new_guid_v4; +pub(crate) mod text_ops; #[cfg(test)] mod p1_tests; diff --git a/src/app/control/p1_tests.rs b/src/app/control/p1_tests.rs index 705e5e639..445368372 100644 --- a/src/app/control/p1_tests.rs +++ b/src/app/control/p1_tests.rs @@ -566,3 +566,92 @@ fn state_reports_the_hand_seed() { let after = u64::from_str_radix(state["hand_seed"].as_str().unwrap(), 16).unwrap(); assert!(after > before, "issuing a handle advances the seed"); } + +#[test] +fn text_search_op_queries_text_and_attributes() { + 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":"ts_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;POMPE A EAU}","layer":"0"}, + {"type":"Text","position":[20,0,0],"value":"BAROMETRE","layer":"0"}, + {"type":"Text","position":[30,0,0],"value":"10 BAR","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Case-insensitive search finds CIRTUITS + let res = app.automation_op(r#"{"op":"text_search","find":"cirtuits"}"#); + assert_eq!(res["ok"], true); + assert_eq!(res["count"], 1); + assert_eq!(res["matches"][0]["match_text"], "cirtuits"); + assert_eq!(res["matches"][0]["plain_text"], "CIRTUITS 28-09-2026"); + + // Case-sensitive search with wrong case finds nothing + let res_cs = app.automation_op(r#"{"op":"text_search","find":"cirtuits","match_case":true}"#); + assert_eq!(res_cs["ok"], true); + assert_eq!(res_cs["count"], 0); + + // Whole-word search: "bar" matches "10 BAR" but not "BAROMETRE" + let res_ww = app.automation_op(r#"{"op":"text_search","find":"bar","whole_word":true}"#); + assert_eq!(res_ww["ok"], true); + assert_eq!(res_ww["count"], 1); + assert_eq!(res_ww["matches"][0]["plain_text"], "10 BAR"); + + // Non-whole-word search finds both "BAROMETRE" and "10 BAR" + let res_all = app.automation_op(r#"{"op":"text_search","find":"bar","whole_word":false}"#); + assert_eq!(res_all["ok"], true); + assert_eq!(res_all["count"], 2); +} + +#[test] +fn text_replace_op_batch_replaces_preserves_formatting_and_undoes() { + 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_c1","document_id":{doc},"entities":[ + {"type":"Text","position":[0,0,0],"value":"VANNE A ARRET","layer":"0"}, + {"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic|b0|i0;V15-CUVE EAU NON TRAITE}","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Execute batch replace with 2 pairs in one atomic step + let rep = mutate( + &mut app, + r#"{"protocol":1,"op":"text_replace","request_id":"tr_r1","document_id":{doc},"pairs":[ + {"find":"NON TRAITE","replace":"NON TRAITÉE"}, + {"find":"VANNE A ARRET","replace":"VANNE D'ARRÊT"} + ]}"#, + ); + assert_eq!(rep["ok"], true, "{}", rep["error"]); + assert_eq!(rep["result"]["replaced"], 2); + assert_eq!(rep["result"]["entities_changed"], 2); + + // Verify MText formatting codes were preserved + let q = app.automation_op(r#"{"op":"text_search","find":"NON TRAITÉE"}"#); + assert_eq!(q["ok"], true); + assert_eq!(q["count"], 1); + let raw = q["matches"][0]["raw_value"].as_str().unwrap(); + assert!(raw.contains("Century Gothic"), "raw MText should keep font format code: {raw}"); + assert!(raw.contains("NON TRAITÉE"), "raw MText should contain replaced text: {raw}"); + + // Verify Text was replaced + let q2 = app.automation_op(r#"{"op":"text_search","find":"VANNE D'ARRÊT"}"#); + assert_eq!(q2["ok"], true); + assert_eq!(q2["count"], 1); + + // Undo restores the original text in one step + app.automation_op(r#"{"op":"undo"}"#); + let q_orig = app.automation_op(r#"{"op":"text_search","find":"NON TRAITE"}"#); + assert_eq!(q_orig["ok"], true); + assert_eq!(q_orig["count"], 1); + + let q_orig2 = app.automation_op(r#"{"op":"text_search","find":"VANNE A ARRET"}"#); + assert_eq!(q_orig2["ok"], true); + assert_eq!(q_orig2["count"], 1); +} + diff --git a/src/app/control/text_ops.rs b/src/app/control/text_ops.rs new file mode 100644 index 000000000..e0bf2f919 --- /dev/null +++ b/src/app/control/text_ops.rs @@ -0,0 +1,1270 @@ +//! High-performance text search and replacement operations for OpenCADStudio. +//! +//! Provides headless, control-bridge, and MCP-accessible text querying and batch +//! replacement across Text, MText, Dimension, AttributeDefinition, and Insert entities. +//! Safely preserves MText inline formatting codes (fonts, colors, heights, braces) +//! and handles DXF Unicode escapes (`\U+XXXX`) and special character codes (`%%d`, `%%p`, etc.). + +use super::{failure, OpenCADStudio}; +use crate::app::Message; +use codec::{CadDocument, EntityType, Handle}; +use iced::Task; +use serde_json::{json, Value}; + +/// Decodes DXF special character codes (`%%d`, `%%p`, `%%c`, `%%nnn`) and +/// DXF Unicode escape sequences (`\U+XXXX` / `\u+XXXX`) into UTF-8 characters. +pub fn decode_dxf_escapes(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '%' && chars.peek() == Some(&'%') { + chars.next(); // consume second '%' + match chars.peek().map(|ch| ch.to_ascii_lowercase()) { + Some('d') => { + chars.next(); + out.push('°'); + } + Some('p') => { + chars.next(); + out.push('±'); + } + Some('c') => { + chars.next(); + out.push('∅'); + } + Some('u') | Some('o') => { + chars.next(); // toggle codes — strip silently + } + Some('%') => { + chars.next(); + if chars.peek() == Some(&'%') { + chars.next(); + } + out.push('%'); + } + Some(d) if d.is_ascii_digit() => { + let mut digits = String::with_capacity(3); + for _ in 0..3 { + match chars.peek() { + Some(&ch) if ch.is_ascii_digit() => { + digits.push(chars.next().unwrap()); + } + _ => break, + } + } + if digits.len() == 3 { + if let Ok(n) = digits.parse::() { + let ch_opt = match n { + 128 => Some('€'), + 130 => Some('‚'), + 131 => Some('ƒ'), + 132 => Some('„'), + 133 => Some('…'), + 134 => Some('†'), + 135 => Some('‡'), + 136 => Some('ˆ'), + 137 => Some('‰'), + 138 => Some('Š'), + 139 => Some('‹'), + 140 => Some('Œ'), + 142 => Some('Ž'), + 145 => Some('‘'), + 146 => Some('’'), + 147 => Some('“'), + 148 => Some('”'), + 149 => Some('•'), + 150 => Some('–'), + 151 => Some('—'), + 152 => Some('˜'), + 153 => Some('™'), + 154 => Some('š'), + 155 => Some('›'), + 156 => Some('œ'), + 158 => Some('ž'), + 159 => Some('Ÿ'), + 160..=255 => char::from_u32(n), + _ => char::from_u32(n), + }; + if let Some(ch) = ch_opt { + out.push(ch); + continue; + } + } + } + out.push('%'); + out.push('%'); + out.push_str(&digits); + } + _ => { + out.push('%'); + out.push('%'); + } + } + continue; + } + + // DXF Unicode escape: \U+XXXX or \u+XXXX + if c == '\\' && (chars.peek() == Some(&'U') || chars.peek() == Some(&'u')) { + let mut clone = chars.clone(); + clone.next(); // consume U/u + if clone.peek() == Some(&'+') { + clone.next(); // consume '+' + let mut hex = String::with_capacity(4); + for _ in 0..4 { + match clone.peek() { + Some(&ch) if ch.is_ascii_hexdigit() => { + hex.push(clone.next().unwrap()); + } + _ => break, + } + } + if hex.len() == 4 { + if let Ok(codepoint) = u32::from_str_radix(&hex, 16) { + if let Some(ch) = char::from_u32(codepoint) { + out.push(ch); + chars = clone; // advance chars + continue; + } + } + } + } + } + + out.push(c); + } + + out +} + +/// Strips common Latin diacritics/accents into their plain ASCII equivalents (e.g. 'é' -> 'e', 'À' -> 'A'). +pub fn remove_diacritics(s: &str) -> String { + s.chars() + .map(|c| match c { + 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => 'a', + 'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => 'A', + 'è' | 'é' | 'ê' | 'ë' => 'e', + 'È' | 'É' | 'Ê' | 'Ë' => 'E', + 'ì' | 'í' | 'î' | 'ï' => 'i', + 'Ì' | 'Í' | 'Î' | 'Ï' => 'I', + 'ò' | 'ó' | 'ô' | 'õ' | 'ö' => 'o', + 'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' => 'O', + 'ù' | 'ú' | 'û' | 'ü' => 'u', + 'Ù' | 'Ú' | 'Û' | 'Ü' => 'U', + 'ý' | 'ÿ' => 'y', + 'Ý' => 'Y', + 'ç' => 'c', + 'Ç' => 'C', + 'ñ' => 'n', + 'Ñ' => 'N', + other => other, + }) + .collect() +} + +/// Converts LF / CRLF newlines into DXF MText paragraph breaks (`\P`). +pub fn to_raw_mtext_replacement(s: &str) -> String { + s.replace("\r\n", "\\P").replace('\n', "\\P") +} + +/// Converts raw DXF MText paragraph breaks (`\P` / `\p`) into plain text newlines (`\n`). +pub fn to_plain_text_search(s: &str) -> String { + s.replace("\\P", "\n").replace("\\p", "\n") +} + +/// Converts newlines (`\r\n` / `\n`) into raw DXF MText paragraph breaks (`\P`). +pub fn to_raw_mtext_search(s: &str) -> String { + s.replace("\r\n", "\\P").replace('\n', "\\P") +} + +/// Pattern matcher supporting case-sensitive/case-insensitive, whole-word, +/// and accent/diacritic-insensitive matching across Unicode and ASCII strings. +#[derive(Debug, Clone)] +pub struct TextMatcher { + pub search: String, + pub match_case: bool, + pub whole_word: bool, + pub ignore_accents: bool, +} + +impl TextMatcher { + pub fn new( + search: String, + match_case: bool, + whole_word: bool, + ignore_accents: bool, + ) -> Self { + Self { + search, + match_case, + whole_word, + ignore_accents, + } + } + + pub fn with_search(&self, search: String) -> Self { + Self { + search, + match_case: self.match_case, + whole_word: self.whole_word, + ignore_accents: self.ignore_accents, + } + } + + pub fn matches(&self, hay: &str) -> bool { + !self.find_all_in(hay).is_empty() + } + + /// Matches against both plain display text and raw CAD string, normalizing + /// newlines and MText paragraph break escapes (`\n` <-> `\P`). + pub fn matches_text_or_raw(&self, plain_text: &str, raw_val: &str) -> bool { + if self.matches(plain_text) || self.matches(raw_val) { + return true; + } + if self.search.contains('\n') || self.search.contains("\r\n") { + let p_matcher = self.with_search(to_raw_mtext_search(&self.search)); + if p_matcher.matches(raw_val) { + return true; + } + } + if self.search.contains("\\P") || self.search.contains("\\p") { + let lf_matcher = self.with_search(to_plain_text_search(&self.search)); + if lf_matcher.matches(plain_text) { + return true; + } + } + false + } + + /// Finds all non-overlapping match slices `(start_byte, end_byte)` in `hay`. + pub fn find_all_in(&self, hay: &str) -> Vec<(usize, usize)> { + if self.search.is_empty() || hay.is_empty() { + return Vec::new(); + } + + // Fast path: exact case-sensitive, accent-sensitive substring search + if self.match_case && !self.ignore_accents && !self.whole_word { + let mut matches = Vec::new(); + let mut cursor = 0; + while cursor < hay.len() { + if let Some(idx) = hay[cursor..].find(&self.search) { + let start = cursor + idx; + let end = start + self.search.len(); + matches.push((start, end)); + cursor = end; + } else { + break; + } + } + return matches; + } + + // Character-indexed search for case-insensitive, whole-word, and/or accent-insensitive matching + let hay_chars: Vec = hay.chars().collect(); + let mut byte_offsets = Vec::with_capacity(hay_chars.len() + 1); + let mut cur_byte = 0; + for &c in &hay_chars { + byte_offsets.push(cur_byte); + cur_byte += c.len_utf8(); + } + byte_offsets.push(cur_byte); + + let needle_norm: Vec = { + let s = if self.ignore_accents { + remove_diacritics(&self.search) + } else { + self.search.clone() + }; + if self.match_case { + s.chars().collect() + } else { + s.to_lowercase().chars().collect() + } + }; + + let hay_norm: Vec = { + let s = if self.ignore_accents { + remove_diacritics(hay) + } else { + hay.to_string() + }; + if self.match_case { + s.chars().collect() + } else { + s.to_lowercase().chars().collect() + } + }; + + let mut matches = Vec::new(); + let n_len = needle_norm.len(); + if n_len > 0 && n_len <= hay_norm.len() { + let mut i = 0; + while i + n_len <= hay_norm.len() { + if hay_norm[i..i + n_len] == needle_norm[..] { + let start_byte = byte_offsets[i]; + let end_byte = byte_offsets[i + n_len]; + + let is_word_boundary = if self.whole_word { + let before_ok = if i == 0 { + true + } else { + !is_word_char(hay_chars[i - 1]) + }; + let after_ok = if i + n_len >= hay_chars.len() { + true + } else { + !is_word_char(hay_chars[i + n_len]) + }; + before_ok && after_ok + } else { + true + }; + + if is_word_boundary { + matches.push((start_byte, end_byte)); + i += n_len; + continue; + } + } + i += 1; + } + } + + matches + } + + /// Replaces occurrences in `hay` with `replacement`. + pub fn replace_all_in( + &self, + hay: &str, + replacement: &str, + replace_all: bool, + ) -> (String, usize) { + let matches = self.find_all_in(hay); + if matches.is_empty() { + return (hay.to_string(), 0); + } + + let mut result = String::with_capacity(hay.len()); + let mut cursor = 0; + let mut count = 0; + + for (start, end) in matches { + result.push_str(&hay[cursor..start]); + result.push_str(replacement); + cursor = end; + count += 1; + if !replace_all { + break; + } + } + result.push_str(&hay[cursor..]); + (result, count) + } +} + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +fn has_trailing_paragraph_break(raw: &str) -> bool { + let s = raw.trim_end(); + let s = s.strip_suffix('}').unwrap_or(s); + match s.strip_suffix('P') { + Some(rest) => rest.chars().rev().take_while(|&c| c == '\\').count() % 2 == 1, + None => false, + } +} + +/// Safely replaces text within an MText string by modifying text runs inside +/// the parsed MText structure, preserving all font tags, colors, and line breaks. +/// Supports both single-span replacements and multiline replacements across +/// paragraph breaks (`\P` / `\n`). +pub fn replace_mtext_safe( + raw: &str, + matcher: &TextMatcher, + replacement: &str, + replace_all: bool, +) -> (String, usize) { + if raw.is_empty() { + return (raw.to_string(), 0); + } + + // Convert replacement newlines to \P for MText compatibility + let raw_replacement = to_raw_mtext_replacement(replacement); + + let mut doc = codec::entities::mtext_format::parse_mtext(raw, true); + let mut total_replaced = 0; + + // First attempt: replace within individual parsed spans (preserves fine-grained formatting) + for para in &mut doc.paragraphs { + for span in &mut para.spans { + if span.stacking.is_none() { + let (new_text, count) = + matcher.replace_all_in(&span.text, &raw_replacement, replace_all); + if count > 0 { + span.text = new_text; + total_replaced += count; + if !replace_all { + break; + } + } + } + } + if !replace_all && total_replaced > 0 { + break; + } + } + + if total_replaced > 0 { + if has_trailing_paragraph_break(raw) { + doc.paragraphs + .push(codec::entities::mtext_format::MTextParagraph::new()); + } + (doc.to_mtext_string(), total_replaced) + } else { + // Second attempt: raw string replacement + // Handles multiline text where the search pattern spans across paragraph breaks (\P / \n) + replace_mtext_raw_flexible(raw, matcher, &raw_replacement, replace_all) + } +} + +/// Helper for flexible raw MText replacement handling newlines and paragraph breaks. +fn replace_mtext_raw_flexible( + raw: &str, + matcher: &TextMatcher, + raw_replacement: &str, + replace_all: bool, +) -> (String, usize) { + // 1. Try direct raw replacement with current matcher + let (direct_res, direct_count) = matcher.replace_all_in(raw, raw_replacement, replace_all); + if direct_count > 0 { + return (direct_res, direct_count); + } + + // 2. If search pattern contains newlines (\r\n or \n), convert them to \P and try + if matcher.search.contains('\n') || matcher.search.contains("\r\n") { + let p_search = to_raw_mtext_search(&matcher.search); + let p_matcher = matcher.with_search(p_search); + let (p_res, p_count) = p_matcher.replace_all_in(raw, raw_replacement, replace_all); + if p_count > 0 { + return (p_res, p_count); + } + + // 3. Multiline paragraph-boundary matching with flexible whitespace: + // Handles cases where raw has spaces around \P, or paragraph formatting like \pxqc; + let lines: Vec<&str> = matcher + .search + .split('\n') + .map(|l| l.strip_suffix('\r').unwrap_or(l)) + .collect(); + + if lines.len() >= 2 { + let (flex_res, flex_count) = + replace_raw_lines(raw, &lines, matcher, raw_replacement, replace_all); + if flex_count > 0 { + return (flex_res, flex_count); + } + } + } else if matcher.search.contains("\\P") || matcher.search.contains("\\p") { + // Search contained literal \P; try with \n in case raw has raw LF + let lf_search = to_plain_text_search(&matcher.search); + let lf_matcher = matcher.with_search(lf_search); + let (lf_res, lf_count) = lf_matcher.replace_all_in(raw, raw_replacement, replace_all); + if lf_count > 0 { + return (lf_res, lf_count); + } + } + + (raw.to_string(), 0) +} + +/// Helper to match line sequences across raw MText paragraph breaks (\P / \p / tags / spaces). +fn replace_raw_lines( + raw: &str, + lines: &[&str], + matcher: &TextMatcher, + raw_replacement: &str, + replace_all: bool, +) -> (String, usize) { + if lines.len() < 2 || raw.is_empty() { + return (raw.to_string(), 0); + } + + let line0 = lines[0].trim_end(); + let line_last = lines[lines.len() - 1].trim_start(); + let middle_lines: Vec<&str> = lines[1..lines.len() - 1].iter().map(|l| l.trim()).collect(); + + let m0 = matcher.with_search(line0.to_string()); + let m_last = matcher.with_search(line_last.to_string()); + let m_mids: Vec = middle_lines + .iter() + .map(|l| matcher.with_search(l.to_string())) + .collect(); + + let matches0 = m0.find_all_in(raw); + if matches0.is_empty() { + return (raw.to_string(), 0); + } + + let mut result = String::with_capacity(raw.len()); + let mut cursor = 0; + let mut count = 0; + + for (start0, end0) in matches0 { + if start0 < cursor { + continue; // already replaced + } + + // Try to verify subsequent lines separated by MText paragraph breaks + let mut check_pos = end0; + let mut matched_all = true; + + for m_mid in &m_mids { + if let Some(next_pos) = skip_mtext_paragraph_break(raw, check_pos) { + let rest = &raw[next_pos..]; + let mid_matches = m_mid.find_all_in(rest); + if let Some(&(m_s, m_e)) = mid_matches.first() { + if m_s == 0 { + check_pos = next_pos + m_e; + continue; + } + } + } + matched_all = false; + break; + } + + if !matched_all { + continue; + } + + if let Some(next_pos) = skip_mtext_paragraph_break(raw, check_pos) { + let rest = &raw[next_pos..]; + let last_matches = m_last.find_all_in(rest); + if let Some(&(l_s, l_e)) = last_matches.first() { + if l_s == 0 { + let end_pos = next_pos + l_e; + result.push_str(&raw[cursor..start0]); + result.push_str(raw_replacement); + cursor = end_pos; + count += 1; + if !replace_all { + break; + } + continue; + } + } + } + } + + if count > 0 { + result.push_str(&raw[cursor..]); + (result, count) + } else { + (raw.to_string(), 0) + } +} + +/// Skips over an MText paragraph break: optional whitespace, `\P` or `\p`, optional format tags like `\pxqc;`, optional whitespace. +fn skip_mtext_paragraph_break(raw: &str, from: usize) -> Option { + if from >= raw.len() { + return None; + } + let bytes = raw.as_bytes(); + let mut i = from; + + // Skip whitespace before \P + while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { + i += 1; + } + + // Must have \P or \p + if i + 1 < bytes.len() && bytes[i] == b'\\' && (bytes[i + 1] == b'P' || bytes[i + 1] == b'p') { + i += 2; + } else { + return None; + } + + // Skip optional paragraph format tags like \pxqc; or \pi...; + if i < bytes.len() && bytes[i] == b'\\' { + let mut tag_end = i + 1; + while tag_end < bytes.len() + && bytes[tag_end] != b';' + && bytes[tag_end] != b'\\' + && bytes[tag_end] != b'{' + && bytes[tag_end] != b'}' + { + tag_end += 1; + } + if tag_end < bytes.len() && bytes[tag_end] == b';' { + i = tag_end + 1; + } + } + + // Skip whitespace after \P + while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { + i += 1; + } + + Some(i) +} + +/// Extracts the plain display text and position for an entity. +pub fn entity_text_info( + entity: &EntityType, + document: &CadDocument, +) -> Option<(String, String, [f64; 3])> { + // Returns (raw_value, plain_text, [x, y, z]) + match entity { + EntityType::Text(t) => { + let raw = t.value.clone(); + let plain = decode_dxf_escapes(&raw); + let pos = [t.insertion_point.x, t.insertion_point.y, t.insertion_point.z]; + Some((raw, plain, pos)) + } + EntityType::MText(t) => { + let raw = t.value.clone(); + let plain_raw = codec::entities::mtext_format::parse_mtext(&raw, true).to_plain_text(); + let plain = decode_dxf_escapes(&plain_raw); + let pos = [t.insertion_point.x, t.insertion_point.y, t.insertion_point.z]; + Some((raw, plain, pos)) + } + EntityType::AttributeDefinition(ad) => { + let raw = ad.default_value.clone(); + let plain = decode_dxf_escapes(&raw); + let pos = [ad.insertion_point.x, ad.insertion_point.y, ad.insertion_point.z]; + Some((raw, plain, pos)) + } + EntityType::AttributeEntity(ae) => { + let raw = ae.get_value().to_string(); + let plain = decode_dxf_escapes(&raw); + let pos = [ae.insertion_point.x, ae.insertion_point.y, ae.insertion_point.z]; + Some((raw, plain, pos)) + } + EntityType::Dimension(dim) => { + let text = dim.base().text.clone(); + if text.is_empty() { + None + } else { + let plain = decode_dxf_escapes(&text); + let p = dim.base().text_middle_point; + Some((text, plain, [p.x, p.y, p.z])) + } + } + _ => { + let _ = document; + None + } + } +} + +fn parse_bounds_filter(req: &Value) -> Result, Value> { + let Some(bounds_val) = req.get("bounds") else { + return Ok(None); + }; + let bounds = bounds_val.as_array().and_then(|values| { + if values.len() != 4 { + return None; + } + let min_x = values[0].as_f64()?; + let min_y = values[1].as_f64()?; + let max_x = values[2].as_f64()?; + let max_y = values[3].as_f64()?; + Some([min_x, min_y, max_x, max_y]) + }); + let Some(bounds) = bounds else { + return Err(failure( + "invalid_bounds", + "bounds expects [min_x, min_y, max_x, max_y]", + )); + }; + if !bounds.iter().all(|v| v.is_finite()) || bounds[0] > bounds[2] || bounds[1] > bounds[3] { + return Err(failure( + "invalid_bounds", + "bounds must be finite with min <= max", + )); + } + Ok(Some(bounds)) +} + +fn parse_handles_filter(req: &Value) -> Option> { + if let Some(h) = req["handle"].as_str() { + u64::from_str_radix(h.trim_start_matches("0x"), 16) + .ok() + .map(|val| vec![Handle::new(val)]) + } else if let Some(arr) = req["handles"].as_array() { + let handles: Vec<_> = arr + .iter() + .filter_map(|v| { + let s = v.as_str()?; + u64::from_str_radix(s.trim_start_matches("0x"), 16) + .ok() + .map(Handle::new) + }) + .collect(); + (!handles.is_empty()).then_some(handles) + } else { + None + } +} + +fn parse_types_filter(req: &Value) -> Option> { + if let Some(t) = req["type"].as_str() { + Some(vec![t.to_string()]) + } else if let Some(arr) = req["types"].as_array() { + let types: Vec<_> = arr + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(); + (!types.is_empty()).then_some(types) + } else { + None + } +} + +fn entity_block_name(doc: &CadDocument, entity_handle: Handle, owner: Handle) -> Option { + if let Some(record) = doc.block_records.iter().find(|r| r.handle == owner) { + return Some(record.name.clone()); + } + for record in doc.block_records.iter() { + if record.entity_handles.contains(&entity_handle) { + return Some(record.name.clone()); + } + } + None +} + +impl OpenCADStudio { + /// `text_search` — find text occurrences across active space entities, inserts, + /// and block definitions with detailed machine-readable metadata. + pub(crate) fn control_text_search(&self, req: &Value) -> Result { + let find = req["find"] + .as_str() + .or_else(|| req["search"].as_str()) + .unwrap_or(""); + if find.is_empty() { + return Err(failure("missing_find", "Supply 'find' parameter")); + } + + 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(500).min(5000) as usize; + + let matcher = TextMatcher::new(find.to_string(), match_case, whole_word, ignore_accents); + let i = self.active_tab; + let scene = &self.tabs[i].scene; + let doc = &scene.document; + + let mut matches = Vec::new(); + + for entity in doc.entities() { + let common = entity.common(); + let handle = common.handle; + + // Scope filter + 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, + _ => {} + } + + // Layer filter + if let Some(l) = layer_filter { + if !common.layer.eq_ignore_ascii_case(l) { + continue; + } + } + + // Handle filter + if let Some(ref h_list) = handles_filter { + if !h_list.contains(&handle) { + continue; + } + } + + // Types filter + if let Some(ref t_list) = types_filter { + if !t_list + .iter() + .any(|t| crate::app::automation::entity_type_matches(entity, t)) + { + continue; + } + } + + // Bounds filter + 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 block_name = entity_block_name(doc, handle, common.owner_handle); + + // Check standard text entities + if let Some((raw_val, plain_text, pos)) = entity_text_info(entity, doc) { + if matcher.matches_text_or_raw(&plain_text, &raw_val) { + let matched_text = find.to_string(); + matches.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": crate::entities::names::ui_name(entity), + "layer": common.layer, + "space": if in_active { "active" } else { "definition" }, + "block": block_name, + "position": pos, + "raw_value": raw_val, + "plain_text": plain_text, + "match_text": matched_text, + })); + if matches.len() >= limit { + break; + } + } + } + + // Check Insert attributes + if let EntityType::Insert(insert) = entity { + for (idx, attr) in insert.attributes.iter().enumerate() { + let raw = attr.get_value(); + let plain = decode_dxf_escapes(raw); + if matcher.matches_text_or_raw(&plain, raw) { + matches.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "Insert", + "layer": common.layer, + "space": if in_active { "active" } else { "definition" }, + "block": insert.block_name, + "attribute_tag": attr.tag, + "attribute_index": idx, + "position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z], + "raw_value": raw, + "plain_text": plain, + "match_text": find, + })); + if matches.len() >= limit { + break; + } + } + } + if matches.len() >= limit { + break; + } + } + } + + let truncated = matches.len() >= limit; + Ok(json!({ + "ok": true, + "count": matches.len(), + "truncated": truncated, + "matches": matches, + })) + } + + /// 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; + + // Parse search/replace pairs + let mut pairs: Vec<(String, String)> = Vec::new(); + if let Some(pair_array) = req["pairs"].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())); + } + } + } else if let Some(find) = req["find"].as_str() { + if !find.is_empty() { + let replace = req["replace"].as_str().unwrap_or(""); + pairs.push((find.to_string(), replace.to_string())); + } + } + + if pairs.is_empty() { + return Err(failure( + "missing_find", + "Supply 'find'/'replace' or 'pairs' for text_replace", + )); + } + + 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 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(); + let handles_filter = parse_handles_filter(req); + let types_filter = parse_types_filter(req); + let bounds_filter = parse_bounds_filter(req)?; + + let matchers: Vec<(TextMatcher, String)> = pairs + .into_iter() + .map(|(f, r)| (TextMatcher::new(f, match_case, whole_word, ignore_accents), r)) + .collect(); + + if push_undo { + self.push_undo_snapshot(i, "TEXT REPLACE"); + } + + let mut total_replaced = 0usize; + let mut changed_handles = Vec::new(); + let mut changes_log = Vec::new(); + + // Collect all entity handles that match filters + let candidate_handles: Vec = { + let scene = &self.tabs[i].scene; + let doc = &scene.document; + let mut candidates = 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; + } + } + + if scene.is_layer_locked(handle) { + continue; + } + + candidates.push(handle); + } + candidates + }; + + // Mutate matching entities + let doc = &mut self.tabs[i].scene.document; + for handle in candidate_handles { + let Some(entity) = doc.get_entity_mut(handle) else { + continue; + }; + + let mut entity_replaced = 0; + let mut before_val = None; + let mut after_val = None; + + match entity { + EntityType::Text(text) => { + for (matcher, replacement) in &matchers { + let (new_val, count) = + matcher.replace_all_in(&text.value, replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(text.value.clone()); + } + text.value = new_val; + entity_replaced += count; + after_val = Some(text.value.clone()); + } else { + // Check if matching decoded DXF escapes + let decoded = decode_dxf_escapes(&text.value); + if matcher.matches(&decoded) { + let (new_val, c) = + matcher.replace_all_in(&decoded, replacement, replace_all); + if c > 0 { + if before_val.is_none() { + before_val = Some(text.value.clone()); + } + text.value = new_val; + entity_replaced += c; + after_val = Some(text.value.clone()); + } + } + } + } + } + EntityType::MText(mtext) => { + for (matcher, replacement) in &matchers { + let (new_val, count) = + replace_mtext_safe(&mtext.value, matcher, replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(mtext.value.clone()); + } + mtext.value = new_val; + entity_replaced += count; + after_val = Some(mtext.value.clone()); + } + } + } + EntityType::AttributeDefinition(ad) => { + for (matcher, replacement) in &matchers { + let (new_val, count) = + matcher.replace_all_in(&ad.default_value, replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(ad.default_value.clone()); + } + ad.default_value = new_val; + entity_replaced += count; + after_val = Some(ad.default_value.clone()); + } + } + } + EntityType::AttributeEntity(ae) => { + for (matcher, replacement) in &matchers { + let (new_val, count) = + matcher.replace_all_in(ae.get_value(), replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(ae.get_value().to_string()); + } + ae.set_value(new_val); + entity_replaced += count; + after_val = Some(ae.get_value().to_string()); + } + } + } + EntityType::Dimension(dim) => { + let base = dim.base_mut(); + if !base.text.is_empty() { + for (matcher, replacement) in &matchers { + let (new_val, count) = + matcher.replace_all_in(&base.text, replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(base.text.clone()); + } + base.text = new_val; + entity_replaced += count; + after_val = Some(base.text.clone()); + } + } + } + } + EntityType::Insert(insert) => { + for attr in &mut insert.attributes { + for (matcher, replacement) in &matchers { + let (new_val, count) = + 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()); + total_replaced += count; + changes_log.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "Insert", + "attribute_tag": attr.tag, + "before": old_val, + "after": new_val, + "replaced": count, + })); + if !changed_handles.contains(&handle) { + changed_handles.push(handle); + } + } + } + } + } + _ => {} + } + + if entity_replaced > 0 { + total_replaced += entity_replaced; + changes_log.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": crate::entities::names::ui_name(entity), + "before": before_val, + "after": after_val, + "replaced": entity_replaced, + })); + if !changed_handles.contains(&handle) { + changed_handles.push(handle); + } + } + } + + if total_replaced == 0 { + if push_undo { + self.discard_last_undo_entry(i); + } + return Ok(json!({ + "ok": true, + "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(); + + 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, + "replaced": total_replaced, + "entities_changed": changed_handles.len(), + "changes": changes_log, + })) + } + + /// Bridge for `control_request` mutating op `"text_replace"`. + pub(super) fn control_text_replace(&mut self, req: &Value) -> Result, Value> { + let result = self.execute_text_replace(req, true)?; + self.set_control_result(result); + Ok(Task::none()) + } + + /// Headless automation text search. + pub(crate) fn automation_text_search(&self, req: &Value) -> Value { + self.control_text_search(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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_dxf_escapes() { + assert_eq!(decode_dxf_escapes("CIRCUITS"), "CIRCUITS"); + assert_eq!(decode_dxf_escapes("R\\U+00C9SERVOIR"), "RÉSERVOIR"); + assert_eq!(decode_dxf_escapes("r\\u+00e9servoir"), "réservoir"); + assert_eq!(decode_dxf_escapes("Angle %%d"), "Angle °"); + assert_eq!(decode_dxf_escapes("Tolerance %%p0.05"), "Tolerance ±0.05"); + assert_eq!(decode_dxf_escapes("Diameter %%c50"), "Diameter ∅50"); + assert_eq!(decode_dxf_escapes("100%%%%"), "100%"); + // Windows-1252 / ISO-8859-1 code decoding + assert_eq!(decode_dxf_escapes("Chaudi%%232re"), "Chaudière"); + assert_eq!(decode_dxf_escapes("St%%233 EVERWASH"), "Sté EVERWASH"); + } + + #[test] + fn test_text_matcher_case_and_word_boundaries() { + let tm_ci = TextMatcher::new("bar".into(), false, false, false); + assert!(tm_ci.matches("BAROMETRE")); + assert!(tm_ci.matches("bar")); + assert!(tm_ci.matches("BAR")); + + let tm_ww = TextMatcher::new("bar".into(), false, true, false); + assert!(!tm_ww.matches("BAROMETRE")); + assert!(!tm_ww.matches("MINIBAR")); + assert!(tm_ww.matches("BAR")); + assert!(tm_ww.matches("P = 10 BAR")); + assert!(tm_ww.matches("BAR;")); + + let tm_cs = TextMatcher::new("Bar".into(), true, false, false); + assert!(!tm_cs.matches("BAR")); + assert!(tm_cs.matches("Barometer")); + + let (replaced, count) = tm_ww.replace_all_in("10 BAR ET 20 BAROMETRE", "PSI", true); + assert_eq!(count, 1); + assert_eq!(replaced, "10 PSI ET 20 BAROMETRE"); + } + + #[test] + fn test_text_matcher_accent_insensitive() { + let tm_acc = TextMatcher::new("reservoir".into(), false, false, true); + assert!(tm_acc.matches("RÉSERVOIR")); + assert!(tm_acc.matches("Réservoir")); + assert!(tm_acc.matches("reservoir")); + + let tm_ch = TextMatcher::new("chaudiere".into(), false, false, true); + assert!(tm_ch.matches("Chaudière 1")); + let (replaced, count) = tm_ch.replace_all_in("Chaudière 1 et Chaudière 2", "Boiler", true); + assert_eq!(count, 2); + assert_eq!(replaced, "Boiler 1 et Boiler 2"); + + let tm_bache = TextMatcher::new("bache a eau".into(), false, false, true); + assert!(tm_bache.matches("Bâche à eau 2")); + } + + #[test] + fn test_replace_mtext_safe_preserves_formatting() { + let raw = "{\\fCentury Gothic|b0|i0|c0|p34;V15-CUVE EAU NON TRAITE}"; + let matcher = TextMatcher::new("NON TRAITE".into(), false, false, false); + let (replaced, count) = replace_mtext_safe(raw, &matcher, "NON TRAITÉE", true); + assert_eq!(count, 1); + assert!(replaced.contains("NON TRAITÉE")); + assert!(replaced.contains("Century Gothic")); + assert!(!replaced.contains("NON TRAITE}")); + } + + #[test] + fn test_replace_mtext_multiline() { + // Test 1: Plain LF query matching raw \P + let raw1 = "Loge \\Pgardien"; + let matcher1 = TextMatcher::new("Loge \ngardien".into(), false, false, true); + let (replaced1, count1) = replace_mtext_safe(raw1, &matcher1, "Security \nGuard Booth", true); + assert_eq!(count1, 1); + assert_eq!(replaced1, "Security \\PGuard Booth"); + + // Test 2: Multiline with complex formatting codes preserved + let raw2 = "\\pxqc;{\\fCentury Gothic|b1|i0|c0|p34;ACCÈS ET SORTIE\\PMARCHANDISES}"; + let matcher2 = TextMatcher::new("ACCÈS ET SORTIE\nMARCHANDISES".into(), false, false, true); + let (replaced2, count2) = replace_mtext_safe(raw2, &matcher2, "GOODS ACCESS AND EXIT", true); + assert_eq!(count2, 1); + assert!(replaced2.contains("GOODS ACCESS AND EXIT")); + assert!(replaced2.contains("\\pxqc;")); + assert!(replaced2.contains("Century Gothic")); + + // Test 3: Multiline with paragraph properties and flexible whitespace + let raw3 = "{\\pqc;Office \\PWAREHOUSE}"; + let matcher3 = TextMatcher::new("Office \nWAREHOUSE".into(), false, false, true); + let (replaced3, count3) = replace_mtext_safe(raw3, &matcher3, "WAREHOUSE OFFICE", true); + assert_eq!(count3, 1); + assert_eq!(replaced3, "{\\pqc;WAREHOUSE OFFICE}"); + } +} + diff --git a/src/lib.rs b/src/lib.rs index b0b83da1b..af79d3b6f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ #![allow(non_snake_case)] -#![recursion_limit = "256"] +#![recursion_limit = "512"] pub mod app; #[cfg(not(target_arch = "wasm32"))] diff --git a/src/mcp.rs b/src/mcp.rs index aa704f5a5..ef34d0380 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -42,6 +42,7 @@ const READ_OPS: &[&str] = &[ "operation", "xdata_get", "audit", + "text_search", ]; const EXECUTE_OPS: &[&str] = &[ "new", @@ -63,6 +64,7 @@ const EXECUTE_OPS: &[&str] = &[ "entities_create", "entities_delete", "entities_transform", + "text_replace", "block_define", "block_delete", "xdata_set", @@ -103,6 +105,7 @@ const BATCH_STEP_OPS: &[&str] = &[ "entities_create", "entities_delete", "entities_transform", + "text_replace", "block_define", "block_delete", "xdata_set", @@ -855,6 +858,18 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { r#"{"op":"save_verified","request_id":"deliver-1","path":"/absolute/output.dwg","target_version":"2018"}"#, ) } + "text_replace" => { + if request["pairs"].as_array().is_none() + && request["find"].as_str().is_none_or(str::is_empty) + { + missing( + "find or pairs", + r#"{"op":"text_replace","request_id":"tr1","find":"OLD","replace":"NEW"}"#, + ) + } else { + Ok(()) + } + } _ => Ok(()), } } @@ -1043,6 +1058,14 @@ fn execute_request_schema() -> Value { "camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."}, "selection":{"type":"array","items":handle.clone(),"description":"Expected selected handles from current state."}, "cmd":{"type":"string","minLength":1,"description":"Command name followed by its prompt answers separated by spaces. Points use x,y or x,y,z; option answers use their token. Read command details first when unsure.","examples":["LINE 0,0 10,10","CIRCLE 5,5 3","PLINE 0,0 10,0 10,10 C"]}, + "find":{"type":"string","description":"Search text to replace; supports DXF Unicode escapes and special characters."}, + "replace":{"type":"string","description":"Replacement text."}, + "pairs":{"type":"array","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]},"description":"Batch find/replace pairs executed in a single atomic undo transaction."}, + "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)."}, + "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."}, "block":{"type":"string","description":"Block definition name for wblock (exports its entities flattened into model space)."}, "linked":{"type":"boolean","description":"embed_image: true stores a path-linked RasterImage instead of an embedded OLE2FRAME."}, @@ -1138,6 +1161,7 @@ fn execute_request_schema() -> Value { {"properties":{"op":{"const":"entities_create"}},"required":["entities"]}, {"properties":{"op":{"const":"entities_delete"}},"required":["handles"]}, {"properties":{"op":{"const":"entities_transform"}},"required":["handles"]}, + {"properties":{"op":{"const":"text_replace"}}}, {"properties":{"op":{"const":"block_define"}},"required":["name","base","handles"]}, {"properties":{"op":{"const":"block_delete"}},"required":["name"]}, {"properties":{"op":{"const":"xdata_set"}},"required":["app","handles"]}, @@ -1199,7 +1223,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."},"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."},"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} }, @@ -1570,8 +1594,11 @@ mod tests { assert!(READ_OPS.contains(&"records")); assert!(READ_OPS.contains(&"record_schema")); assert!(READ_OPS.contains(&"audit")); + assert!(READ_OPS.contains(&"text_search")); assert!(EXECUTE_OPS.contains(&"set_properties")); assert!(EXECUTE_OPS.contains(&"save_verified")); + assert!(EXECUTE_OPS.contains(&"text_replace")); + assert!(BATCH_STEP_OPS.contains(&"text_replace")); assert_eq!( tools[2]["inputSchema"]["properties"]["request"]["properties"] ["target_version"]["enum"][0],