forked from 1b-agent/opencadstudio-upstream
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
This commit is contained in:
1 parent
ea91315ba8
commit
88169c1d7a
6 files changed
+1394
-2
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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::<Vec<_>>()})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,5 +1,5 @@
|
||||
#![allow(non_snake_case)]
|
||||
#![recursion_limit = "256"]
|
||||
#![recursion_limit = "512"]
|
||||
|
||||
pub mod app;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
+28
-1
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user