forked from 1b-agent/opencadstudio-upstream
feat(text): native OS spell-checker for text_audit (Windows/macOS/Linux)
Add cross-platform SystemSpeller abstraction with three backends: - Windows: direct COM vtable calls to ISpellChecker (Win32 Spell API), no external crate — uses the same hand-rolled FFI pattern as file_association.rs. Handles ALL-CAPS words by also testing lowercase. - macOS: NSSpellChecker via objc2/objc2-app-kit (already in deps). - Linux: Hunspell .dic file reader + hunspell CLI pipe fallback. text_audit gains three new parameters: system_spellcheck: true — activate OS spell-checker language: en-US — BCP-47 language tag (default: OS default) suggest: true — include spelling suggestions The agent dictionary and system speller work as a layered pipeline: words matching the agent dictionary are accepted immediately; only remaining words are forwarded to the OS checker. Response includes a system_speller object (enabled, available, backend, language) and an unrecognized_words array with handle, position, and optional suggestions. Integration test: text_audit_system_spellcheck_flags_misspelled_words (20/20 p1_tests pass). Release build verified.
This commit is contained in:
1 parent
97ea84d99a
commit
77fbb79917
6 files changed
+1001
-14
No files matched your search
@@ -301,10 +301,58 @@ maps onto these three steps rather than a native op.
|
||||
|
||||
`query` entities of type `TEXT`/`MTEXT` return the raw stored string in `value` plus a formatting-free rendering in `text` (MTEXT inline codes such as `\A1;` or `\P` are resolved; `%%d`-style TEXT codes become their glyphs). With `detail:"full"`, degenerate-width text bounds are widened with a documented estimate (height × 0.8 × character count) so `bounds`-based region filters stay usable.
|
||||
|
||||
### Text operations — search, audit and replace
|
||||
|
||||
Three dedicated text operations go beyond `query` for bulk text work:
|
||||
|
||||
**`text_search`** (read) scans all `Text`, `MText`, and `Insert` attribute values for a pattern. It supports case-insensitive, whole-word, and accent-insensitive matching, scoped to a layer, entity type list, or handle set.
|
||||
|
||||
```json
|
||||
{"op":"text_search","find":"CUVE","match_case":false,"whole_word":true,"ignore_accents":true}
|
||||
```
|
||||
|
||||
**`text_audit`** (read) is a non-mutating spell-check and dry-run replacement preview. It accepts three independent analysis modes, all optional, in one call:
|
||||
|
||||
| Parameter | Effect |
|
||||
|---|---|
|
||||
| `pairs` | `[{"find":"X","replace":"Y"}, …]` — simulates a batch replacement and reports what would change, without touching the document |
|
||||
| `check_terms` | `["CIRTUITS","NON TRAITE"]` — flags entities that contain suspect strings |
|
||||
| `dictionary` | `["EAU","POMPE","VANNE"]` — any word ≥ 3 letters not found in this list is reported as unrecognised |
|
||||
| `system_spellcheck` | `true` to use the OS native spell-checker alongside or instead of the agent dictionary |
|
||||
| `language` | BCP-47 tag for the system speller, e.g. `"en-US"` or `"fr-FR"` (defaults to the OS default) |
|
||||
| `suggest` | `true` (default) to include spelling suggestions for unrecognised words |
|
||||
|
||||
The agent dictionary and system spell-checker work as a layered pipeline: a word that matches the agent dictionary is accepted immediately; only words that fail (or when no dictionary is supplied) are forwarded to the OS spell-checker when enabled.
|
||||
|
||||
System spell-check backends:
|
||||
|
||||
| Platform | Backend | Notes |
|
||||
|---|---|---|
|
||||
| Windows 8.1+ | Win32 `ISpellChecker` COM API | Per-language; checks available via Settings → Language. ALL-CAPS words are checked in lowercase since Windows skips all-caps by default. |
|
||||
| macOS | `NSSpellChecker` | Uses the system language or the `language` parameter |
|
||||
| Linux | Hunspell `.dic` files or `hunspell` CLI | Reads `/usr/share/hunspell/` dictionaries; falls back to pipe mode |
|
||||
|
||||
Example combining an agent dictionary with system spell-check:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "text_audit",
|
||||
"dictionary": ["VANNE", "CHAUDIÈRE", "POMPE"],
|
||||
"system_spellcheck": true,
|
||||
"language": "fr-FR",
|
||||
"suggest": true
|
||||
}
|
||||
```
|
||||
|
||||
The response includes a `system_speller` object with `enabled`, `available`, `backend`, and `language` fields, plus an `unrecognized_words` array with each word's `handle`, `position`, and optional `suggestions`.
|
||||
|
||||
**`text_replace`** (execute) performs the actual batch replacement with single-transaction undo. Add `"dry_run": true` to preview the result without mutating the document or pushing an undo entry.
|
||||
|
||||
### Smoke test
|
||||
|
||||
The stdio path is covered black-box by a Python script that draws two entities, exports them with `wblock`, plots them with `plot`, and checks the capability advertisement:
|
||||
|
||||
|
||||
```sh
|
||||
python3 docs/automation/serve_smoke.py target/debug/OpenCADStudio
|
||||
```
|
||||
|
||||
@@ -1346,6 +1346,7 @@ mod interactive;
|
||||
mod sheets;
|
||||
pub(crate) use sheets::new_guid_v4;
|
||||
pub(crate) mod text_ops;
|
||||
pub(crate) mod spellcheck;
|
||||
|
||||
#[cfg(test)]
|
||||
mod p1_tests;
|
||||
|
||||
@@ -763,4 +763,87 @@ fn text_replace_op_supports_dry_run_without_mutating_or_undo() {
|
||||
assert_eq!(q_undone["count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_audit_system_spellcheck_flags_misspelled_words() {
|
||||
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":"sp_c1","document_id":{doc},"entities":[
|
||||
{"type":"Text","position":[0,0,0],"value":"The cirtuit breaker is conected","layer":"0"},
|
||||
{"type":"Text","position":[10,0,0],"value":"correct english text here","layer":"0"}
|
||||
]}"#,
|
||||
);
|
||||
assert_eq!(r["ok"], true, "{}", r["error"]);
|
||||
|
||||
// Run text_audit with system_spellcheck enabled, English language,
|
||||
// and an agent dictionary that whitelists "breaker"
|
||||
let audit = app.automation_op(
|
||||
r#"{
|
||||
"op":"text_audit",
|
||||
"system_spellcheck": true,
|
||||
"language": "en-US",
|
||||
"suggest": true,
|
||||
"dictionary": ["breaker", "the"]
|
||||
}"#,
|
||||
);
|
||||
assert_eq!(audit["ok"], true, "{}", audit["error"]);
|
||||
|
||||
// system_speller metadata should be present regardless of availability
|
||||
let sp = &audit["system_speller"];
|
||||
assert_eq!(sp["enabled"], true);
|
||||
|
||||
if sp["available"].as_bool() == Some(true) {
|
||||
// Backend should be reported (e.g. "windows" on Windows)
|
||||
assert!(sp["backend"].as_str().unwrap().len() > 0);
|
||||
assert!(sp["language"].as_str().is_some());
|
||||
|
||||
// "cirtuit" and "conected" should be flagged as unrecognized
|
||||
let words = audit["unrecognized_words"].as_array().unwrap();
|
||||
let flagged: Vec<&str> = words.iter().filter_map(|w| w["word"].as_str()).collect();
|
||||
assert!(
|
||||
flagged.iter().any(|w| w.eq_ignore_ascii_case("cirtuit")),
|
||||
"expected 'cirtuit' to be flagged, got: {:?}",
|
||||
flagged
|
||||
);
|
||||
assert!(
|
||||
flagged.iter().any(|w| w.eq_ignore_ascii_case("conected")),
|
||||
"expected 'conected' to be flagged, got: {:?}",
|
||||
flagged
|
||||
);
|
||||
|
||||
// "breaker" should NOT be flagged (it's in the agent dictionary)
|
||||
assert!(
|
||||
!flagged.iter().any(|w| w.eq_ignore_ascii_case("breaker")),
|
||||
"expected 'breaker' to be whitelisted by agent dict, got: {:?}",
|
||||
flagged
|
||||
);
|
||||
|
||||
// At least one flagged word should have suggestions
|
||||
let has_suggestions = words.iter().any(|w| {
|
||||
w["suggestions"]
|
||||
.as_array()
|
||||
.map(|a| !a.is_empty())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
assert!(
|
||||
has_suggestions,
|
||||
"expected at least one word to have suggestions"
|
||||
);
|
||||
|
||||
// Summary should reflect unrecognized words
|
||||
assert!(
|
||||
audit["summary"]["unrecognized_words_count"].as_u64().unwrap() >= 2,
|
||||
"expected at least 2 unrecognized words in summary"
|
||||
);
|
||||
} else {
|
||||
// System speller not available (e.g. CI without spell-check service)
|
||||
// Just verify the response structure is well-formed
|
||||
assert!(audit["unrecognized_words"].is_array());
|
||||
eprintln!(
|
||||
"System spellcheck not available (backend: {}), skipping content assertions",
|
||||
sp["backend"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
//! Native OS spell-checker abstraction supporting Windows, macOS, and Linux.
|
||||
//!
|
||||
//! - **Windows**: Windows 8+ native Spell Checking API (`ISpellChecker` via COM).
|
||||
//! - **macOS**: Apple AppKit `NSSpellChecker` via Objective-C.
|
||||
//! - **Linux**: Standard system dictionary wordlists (`/usr/share/hunspell`, `/usr/share/myspell`,
|
||||
//! `/usr/share/dict`) and Hunspell / Aspell pipe integration.
|
||||
//! - **Other**: Graceful no-op fallback.
|
||||
|
||||
/// Unified system spell checker handle.
|
||||
pub struct SystemSpeller {
|
||||
backend: Backend,
|
||||
language: String,
|
||||
}
|
||||
|
||||
enum Backend {
|
||||
#[cfg(target_os = "windows")]
|
||||
Windows(windows_impl::WindowsSpeller),
|
||||
#[cfg(target_os = "macos")]
|
||||
Mac(macos_impl::MacSpeller),
|
||||
#[cfg(target_os = "linux")]
|
||||
Linux(linux_impl::LinuxSpeller),
|
||||
Fallback(fallback_impl::FallbackSpeller),
|
||||
}
|
||||
|
||||
impl SystemSpeller {
|
||||
/// Create a system speller for the given BCP-47 language tag (e.g. "fr-FR", "en-US", "de-DE", "es-ES"),
|
||||
/// or default to the host system's UI locale.
|
||||
pub fn new(requested_lang: Option<&str>) -> Self {
|
||||
let lang = normalize_language_tag(requested_lang);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(win_speller) = windows_impl::WindowsSpeller::new(&lang) {
|
||||
let actual_lang = win_speller.language().to_string();
|
||||
return Self {
|
||||
backend: Backend::Windows(win_speller),
|
||||
language: actual_lang,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(mac_speller) = macos_impl::MacSpeller::new(&lang) {
|
||||
let actual_lang = mac_speller.language().to_string();
|
||||
return Self {
|
||||
backend: Backend::Mac(mac_speller),
|
||||
language: actual_lang,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(linux_speller) = linux_impl::LinuxSpeller::new(&lang) {
|
||||
let actual_lang = linux_speller.language().to_string();
|
||||
return Self {
|
||||
backend: Backend::Linux(linux_speller),
|
||||
language: actual_lang,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
backend: Backend::Fallback(fallback_impl::FallbackSpeller),
|
||||
language: lang,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a native system spell-checker is active and available.
|
||||
pub fn is_available(&self) -> bool {
|
||||
match &self.backend {
|
||||
#[cfg(target_os = "windows")]
|
||||
Backend::Windows(_) => true,
|
||||
#[cfg(target_os = "macos")]
|
||||
Backend::Mac(_) => true,
|
||||
#[cfg(target_os = "linux")]
|
||||
Backend::Linux(l) => l.is_available(),
|
||||
Backend::Fallback(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the active backend name ("windows", "macos", "linux_system", or "none").
|
||||
pub fn backend_name(&self) -> &'static str {
|
||||
match &self.backend {
|
||||
#[cfg(target_os = "windows")]
|
||||
Backend::Windows(_) => "windows",
|
||||
#[cfg(target_os = "macos")]
|
||||
Backend::Mac(_) => "macos",
|
||||
#[cfg(target_os = "linux")]
|
||||
Backend::Linux(_) => "linux_system",
|
||||
Backend::Fallback(_) => "none",
|
||||
}
|
||||
}
|
||||
|
||||
/// The active language tag (e.g. "fr-FR", "en-US").
|
||||
pub fn language(&self) -> &str {
|
||||
&self.language
|
||||
}
|
||||
|
||||
/// Check if a word is spelled correctly according to the system spell-checker.
|
||||
///
|
||||
/// Returns `(is_correct, suggestions)`.
|
||||
/// When `is_correct` is false, `suggestions` contains system-suggested corrections.
|
||||
pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec<String>) {
|
||||
if word.trim().is_empty() {
|
||||
return (true, Vec::new());
|
||||
}
|
||||
|
||||
match &self.backend {
|
||||
#[cfg(target_os = "windows")]
|
||||
Backend::Windows(w) => w.check_word(word, want_suggestions),
|
||||
#[cfg(target_os = "macos")]
|
||||
Backend::Mac(m) => m.check_word(word, want_suggestions),
|
||||
#[cfg(target_os = "linux")]
|
||||
Backend::Linux(l) => l.check_word(word, want_suggestions),
|
||||
Backend::Fallback(_) => (true, Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_language_tag(input: Option<&str>) -> String {
|
||||
let raw: String = match input {
|
||||
Some(s) if !s.trim().is_empty() => s.trim().to_string(),
|
||||
_ => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let mut buf = [0u16; 85];
|
||||
let n = unsafe { windows_impl::GetUserDefaultLocaleName(buf.as_mut_ptr(), 85) };
|
||||
if n > 1 {
|
||||
String::from_utf16_lossy(&buf[..(n - 1) as usize])
|
||||
} else {
|
||||
"en-US".to_string()
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
if let Ok(lang) = std::env::var("LANG").or_else(|_| std::env::var("LC_ALL")) {
|
||||
let cleaned = lang.split('.').next().unwrap_or("").replace('_', "-");
|
||||
if !cleaned.is_empty() {
|
||||
cleaned
|
||||
} else {
|
||||
"en-US".to_string()
|
||||
}
|
||||
} else {
|
||||
"en-US".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let lower = raw.to_lowercase();
|
||||
match lower.as_str() {
|
||||
"fr" | "french" | "francais" | "français" => "fr-FR".to_string(),
|
||||
"en" | "english" => "en-US".to_string(),
|
||||
"de" | "german" | "deutsch" => "de-DE".to_string(),
|
||||
"es" | "spanish" | "espanol" | "español" => "es-ES".to_string(),
|
||||
"it" | "italian" | "italiano" => "it-IT".to_string(),
|
||||
_ => raw.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Windows Backend: Win32 Spell Checking API (ISpellChecker via COM)
|
||||
// ============================================================================
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) mod windows_impl {
|
||||
use std::ffi::c_void;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
type HRESULT = i32;
|
||||
type BOOL = i32;
|
||||
|
||||
#[repr(C)]
|
||||
struct GUID {
|
||||
data1: u32,
|
||||
data2: u16,
|
||||
data3: u16,
|
||||
data4: [u8; 8],
|
||||
}
|
||||
|
||||
const CLSID_SPELL_CHECKER_FACTORY: GUID = GUID {
|
||||
data1: 0x7ab36653,
|
||||
data2: 0x1796,
|
||||
data3: 0x484b,
|
||||
data4: [0xbd, 0xfa, 0xe7, 0x4f, 0x1d, 0xb7, 0xc1, 0xdc],
|
||||
};
|
||||
|
||||
const IID_ISPELL_CHECKER_FACTORY: GUID = GUID {
|
||||
data1: 0x8e018a9d,
|
||||
data2: 0x2415,
|
||||
data3: 0x4677,
|
||||
data4: [0xbf, 0x08, 0x79, 0x4e, 0xa6, 0x1f, 0x94, 0xbb],
|
||||
};
|
||||
|
||||
#[repr(C)]
|
||||
struct ISpellCheckerFactoryVtbl {
|
||||
query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT,
|
||||
add_ref: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
release: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
get_supported_languages: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> HRESULT,
|
||||
is_supported: unsafe extern "system" fn(*mut c_void, *const u16, *mut BOOL) -> HRESULT,
|
||||
create_spell_checker: unsafe extern "system" fn(*mut c_void, *const u16, *mut *mut c_void) -> HRESULT,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct ISpellCheckerVtbl {
|
||||
query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT,
|
||||
add_ref: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
release: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
get_language_tag: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT,
|
||||
check: unsafe extern "system" fn(*mut c_void, *const u16, *mut *mut c_void) -> HRESULT,
|
||||
suggest: unsafe extern "system" fn(*mut c_void, *const u16, *mut *mut c_void) -> HRESULT,
|
||||
add: unsafe extern "system" fn(*mut c_void, *const u16) -> HRESULT,
|
||||
ignore: unsafe extern "system" fn(*mut c_void, *const u16) -> HRESULT,
|
||||
auto_correct: unsafe extern "system" fn(*mut c_void, *const u16, *const u16) -> HRESULT,
|
||||
get_option_value: unsafe extern "system" fn(*mut c_void, *const u16, *mut u8) -> HRESULT,
|
||||
get_option_ids: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> HRESULT,
|
||||
get_id: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT,
|
||||
get_localized_name: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct IEnumSpellingErrorVtbl {
|
||||
query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT,
|
||||
add_ref: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
release: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
next: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> HRESULT,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct ISpellingErrorVtbl {
|
||||
query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT,
|
||||
add_ref: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
release: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
get_start_index: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT,
|
||||
get_length: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT,
|
||||
get_corrective_action: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT,
|
||||
get_replacement: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct IEnumStringVtbl {
|
||||
query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT,
|
||||
add_ref: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
release: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
next: unsafe extern "system" fn(*mut c_void, u32, *mut *mut u16, *mut u32) -> HRESULT,
|
||||
}
|
||||
|
||||
#[link(name = "ole32")]
|
||||
extern "system" {
|
||||
fn CoInitializeEx(pv_reserved: *const c_void, dw_co_init: u32) -> HRESULT;
|
||||
fn CoCreateInstance(
|
||||
rclsid: *const GUID,
|
||||
p_unk_outer: *mut c_void,
|
||||
dw_cls_context: u32,
|
||||
riid: *const GUID,
|
||||
ppv: *mut *mut c_void,
|
||||
) -> HRESULT;
|
||||
fn CoTaskMemFree(pv: *mut c_void);
|
||||
}
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
pub fn GetUserDefaultLocaleName(lp_locale_name: *mut u16, cch_locale_name: i32) -> i32;
|
||||
}
|
||||
|
||||
fn to_wide(s: &str) -> Vec<u16> {
|
||||
std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect()
|
||||
}
|
||||
|
||||
unsafe fn from_wide_ptr(ptr: *mut u16) -> String {
|
||||
if ptr.is_null() {
|
||||
return String::new();
|
||||
}
|
||||
let mut len = 0;
|
||||
while *ptr.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
let slice = std::slice::from_raw_parts(ptr, len);
|
||||
let s = String::from_utf16_lossy(slice);
|
||||
CoTaskMemFree(ptr as *mut c_void);
|
||||
s
|
||||
}
|
||||
|
||||
pub struct WindowsSpeller {
|
||||
checker_obj: *mut c_void,
|
||||
language: String,
|
||||
}
|
||||
|
||||
// Windows COM objects created with CLSCTX_INPROC_SERVER are safe to invoke from our control thread.
|
||||
unsafe impl Send for WindowsSpeller {}
|
||||
unsafe impl Sync for WindowsSpeller {}
|
||||
|
||||
impl WindowsSpeller {
|
||||
pub fn new(lang_tag: &str) -> Option<Self> {
|
||||
unsafe {
|
||||
CoInitializeEx(std::ptr::null(), 0);
|
||||
|
||||
let mut factory_obj: *mut c_void = std::ptr::null_mut();
|
||||
let hr = CoCreateInstance(
|
||||
&CLSID_SPELL_CHECKER_FACTORY,
|
||||
std::ptr::null_mut(),
|
||||
1, // CLSCTX_INPROC_SERVER
|
||||
&IID_ISPELL_CHECKER_FACTORY,
|
||||
&mut factory_obj,
|
||||
);
|
||||
if hr != 0 || factory_obj.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let factory_vtbl = *(factory_obj as *const *const ISpellCheckerFactoryVtbl);
|
||||
|
||||
// Try requested language, then fallback languages
|
||||
let mut candidates = vec![lang_tag.to_string()];
|
||||
if !candidates.contains(&"en-US".to_string()) {
|
||||
candidates.push("en-US".to_string());
|
||||
}
|
||||
|
||||
let mut chosen_checker = None;
|
||||
let mut chosen_lang = String::new();
|
||||
|
||||
for candidate in candidates {
|
||||
let wide_cand = to_wide(&candidate);
|
||||
let mut supported: BOOL = 0;
|
||||
let hr_supp = ((*factory_vtbl).is_supported)(
|
||||
factory_obj,
|
||||
wide_cand.as_ptr(),
|
||||
&mut supported,
|
||||
);
|
||||
if hr_supp == 0 && supported != 0 {
|
||||
let mut checker_obj: *mut c_void = std::ptr::null_mut();
|
||||
let hr_create = ((*factory_vtbl).create_spell_checker)(
|
||||
factory_obj,
|
||||
wide_cand.as_ptr(),
|
||||
&mut checker_obj,
|
||||
);
|
||||
if hr_create == 0 && !checker_obj.is_null() {
|
||||
chosen_checker = Some(checker_obj);
|
||||
chosen_lang = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
((*factory_vtbl).release)(factory_obj);
|
||||
|
||||
chosen_checker.map(|obj| Self {
|
||||
checker_obj: obj,
|
||||
language: chosen_lang,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn language(&self) -> &str {
|
||||
&self.language
|
||||
}
|
||||
|
||||
pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec<String>) {
|
||||
if self.checker_obj.is_null() {
|
||||
return (true, Vec::new());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let checker_vtbl = *(self.checker_obj as *const *const ISpellCheckerVtbl);
|
||||
let wide_text = to_wide(word);
|
||||
|
||||
let has_err_exact = has_spelling_error(checker_vtbl, self.checker_obj, &wide_text);
|
||||
|
||||
let is_all_caps = word.chars().any(char::is_alphabetic)
|
||||
&& word == word.to_uppercase();
|
||||
|
||||
let mut is_misspelled = has_err_exact;
|
||||
|
||||
// Windows spellcheck by default ignores all-caps words (to avoid flagging acronyms).
|
||||
// In CAD drawings, virtually all text is uppercase (e.g. CIRTUITS, VANNE).
|
||||
// If it wasn't flagged in all-caps, test its lowercase form to check if it's a real word.
|
||||
if !is_misspelled && is_all_caps {
|
||||
let lower = word.to_lowercase();
|
||||
let wide_lower = to_wide(&lower);
|
||||
if has_spelling_error(checker_vtbl, self.checker_obj, &wide_lower) {
|
||||
is_misspelled = true;
|
||||
}
|
||||
}
|
||||
|
||||
let mut suggestions = Vec::new();
|
||||
if is_misspelled && want_suggestions {
|
||||
// Query suggestions for word and/or its lowercase counterpart
|
||||
let mut enum_str: *mut c_void = std::ptr::null_mut();
|
||||
let target_wide = if is_all_caps {
|
||||
to_wide(&word.to_lowercase())
|
||||
} else {
|
||||
wide_text.clone()
|
||||
};
|
||||
|
||||
if ((*checker_vtbl).suggest)(self.checker_obj, target_wide.as_ptr(), &mut enum_str) == 0
|
||||
&& !enum_str.is_null()
|
||||
{
|
||||
let enum_vtbl = *(enum_str as *const *const IEnumStringVtbl);
|
||||
loop {
|
||||
let mut str_ptr: *mut u16 = std::ptr::null_mut();
|
||||
let mut fetched: u32 = 0;
|
||||
if ((*enum_vtbl).next)(enum_str, 1, &mut str_ptr, &mut fetched) != 0
|
||||
|| fetched == 0
|
||||
{
|
||||
break;
|
||||
}
|
||||
let s = from_wide_ptr(str_ptr);
|
||||
if !s.is_empty() {
|
||||
// Match uppercase if original word was uppercase
|
||||
let final_s = if is_all_caps { s.to_uppercase() } else { s };
|
||||
if !suggestions.contains(&final_s) {
|
||||
suggestions.push(final_s);
|
||||
}
|
||||
}
|
||||
if suggestions.len() >= 5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
((*enum_vtbl).release)(enum_str);
|
||||
}
|
||||
}
|
||||
|
||||
(!is_misspelled, suggestions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn has_spelling_error(
|
||||
checker_vtbl: *const ISpellCheckerVtbl,
|
||||
checker_obj: *mut c_void,
|
||||
wide_text: &[u16],
|
||||
) -> bool {
|
||||
let mut errors_obj: *mut c_void = std::ptr::null_mut();
|
||||
let hr_check = ((*checker_vtbl).check)(checker_obj, wide_text.as_ptr(), &mut errors_obj);
|
||||
let mut has_error = false;
|
||||
if hr_check == 0 && !errors_obj.is_null() {
|
||||
let enum_vtbl = *(errors_obj as *const *const IEnumSpellingErrorVtbl);
|
||||
let mut err_item: *mut c_void = std::ptr::null_mut();
|
||||
if ((*enum_vtbl).next)(errors_obj, &mut err_item) == 0 && !err_item.is_null() {
|
||||
has_error = true;
|
||||
((*(*(err_item as *const *const ISpellingErrorVtbl))).release)(err_item);
|
||||
}
|
||||
((*enum_vtbl).release)(errors_obj);
|
||||
}
|
||||
has_error
|
||||
}
|
||||
|
||||
impl Drop for WindowsSpeller {
|
||||
fn drop(&mut self) {
|
||||
if !self.checker_obj.is_null() {
|
||||
unsafe {
|
||||
let vtbl = *(self.checker_obj as *const *const ISpellCheckerVtbl);
|
||||
((*vtbl).release)(self.checker_obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// macOS Backend: NSSpellChecker via AppKit
|
||||
// ============================================================================
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) mod macos_impl {
|
||||
use objc2::msg_send;
|
||||
use objc2::runtime::{AnyClass, AnyObject};
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
pub struct MacSpeller {
|
||||
checker: *mut AnyObject,
|
||||
language: String,
|
||||
}
|
||||
|
||||
unsafe impl Send for MacSpeller {}
|
||||
unsafe impl Sync for MacSpeller {}
|
||||
|
||||
impl MacSpeller {
|
||||
pub fn new(lang_tag: &str) -> Option<Self> {
|
||||
unsafe {
|
||||
let cls = AnyClass::get("NSSpellChecker")?;
|
||||
let checker: *mut AnyObject = msg_send![cls, sharedSpellChecker];
|
||||
if checker.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ns_lang = NSString::from_str(lang_tag);
|
||||
let ok: bool = msg_send![checker, setLanguage: &*ns_lang];
|
||||
let actual_lang = if ok {
|
||||
lang_tag.to_string()
|
||||
} else {
|
||||
let cur_lang: *mut AnyObject = msg_send![checker, language];
|
||||
if !cur_lang.is_null() {
|
||||
let cur: &NSString = &*(cur_lang as *const NSString);
|
||||
cur.to_string()
|
||||
} else {
|
||||
lang_tag.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
Some(Self {
|
||||
checker,
|
||||
language: actual_lang,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn language(&self) -> &str {
|
||||
&self.language
|
||||
}
|
||||
|
||||
pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec<String>) {
|
||||
if self.checker.is_null() || word.is_empty() {
|
||||
return (true, Vec::new());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let is_all_caps = word.chars().any(char::is_alphabetic)
|
||||
&& word == word.to_uppercase();
|
||||
|
||||
let ns_word = NSString::from_str(word);
|
||||
let range: objc2_foundation::NSRange = msg_send![
|
||||
self.checker,
|
||||
checkSpellingOfString: &*ns_word,
|
||||
startingAt: 0isize,
|
||||
language: std::ptr::null::<AnyObject>(),
|
||||
wrap: false,
|
||||
inSpellDocumentWithTag: 0isize,
|
||||
wordCount: std::ptr::null_mut::<isize>()
|
||||
];
|
||||
|
||||
let mut is_misspelled = range.length > 0;
|
||||
|
||||
// Also check lowercase if all-caps
|
||||
if !is_misspelled && is_all_caps {
|
||||
let lower = word.to_lowercase();
|
||||
let ns_lower = NSString::from_str(&lower);
|
||||
let range_lower: objc2_foundation::NSRange = msg_send![
|
||||
self.checker,
|
||||
checkSpellingOfString: &*ns_lower,
|
||||
startingAt: 0isize,
|
||||
language: std::ptr::null::<AnyObject>(),
|
||||
wrap: false,
|
||||
inSpellDocumentWithTag: 0isize,
|
||||
wordCount: std::ptr::null_mut::<isize>()
|
||||
];
|
||||
if range_lower.length > 0 {
|
||||
is_misspelled = true;
|
||||
}
|
||||
}
|
||||
|
||||
let mut suggestions = Vec::new();
|
||||
if is_misspelled && want_suggestions {
|
||||
let target_str = if is_all_caps { word.to_lowercase() } else { word.to_string() };
|
||||
let ns_target = NSString::from_str(&target_str);
|
||||
let word_len = target_str.encode_utf16().count();
|
||||
let r = objc2_foundation::NSRange {
|
||||
location: 0,
|
||||
length: word_len,
|
||||
};
|
||||
let arr: *mut AnyObject = msg_send![
|
||||
self.checker,
|
||||
guessesForWordRange: r,
|
||||
inString: &*ns_target,
|
||||
language: std::ptr::null::<AnyObject>(),
|
||||
inSpellDocumentWithTag: 0isize
|
||||
];
|
||||
if !arr.is_null() {
|
||||
let count: usize = msg_send![arr, count];
|
||||
for i in 0..count.min(5) {
|
||||
let item: *mut AnyObject = msg_send![arr, objectAtIndex: i];
|
||||
if !item.is_null() {
|
||||
let ns_item: &NSString = &*(item as *const NSString);
|
||||
let mut s = ns_item.to_string();
|
||||
if is_all_caps {
|
||||
s = s.to_uppercase();
|
||||
}
|
||||
suggestions.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(!is_misspelled, suggestions)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Linux Backend: System Dictionaries & Hunspell / Aspell
|
||||
// ============================================================================
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) mod linux_impl {
|
||||
use std::collections::HashSet;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const LINUX_DICT_DIRS: &[&str] = &[
|
||||
"/usr/share/hunspell",
|
||||
"/usr/share/myspell",
|
||||
"/usr/share/myspell/dicts",
|
||||
"/var/lib/dictionaries-common/hunspell_dic",
|
||||
"/usr/share/dict",
|
||||
];
|
||||
|
||||
pub struct LinuxSpeller {
|
||||
words: HashSet<String>,
|
||||
language: String,
|
||||
has_cli_tool: bool,
|
||||
}
|
||||
|
||||
impl LinuxSpeller {
|
||||
pub fn new(lang_tag: &str) -> Option<Self> {
|
||||
let mut words = HashSet::new();
|
||||
let mut found_any = false;
|
||||
|
||||
// Search system directories for .dic or wordlist files matching the requested language
|
||||
let short_lang = lang_tag.split('-').next().unwrap_or(lang_tag);
|
||||
let candidates = [
|
||||
format!("{}.dic", lang_tag.replace('-', "_")),
|
||||
format!("{}.dic", lang_tag),
|
||||
format!("{}.dic", short_lang),
|
||||
"words".to_string(),
|
||||
short_lang.to_string(),
|
||||
];
|
||||
|
||||
for dir in LINUX_DICT_DIRS {
|
||||
let base = Path::new(dir);
|
||||
if !base.is_dir() {
|
||||
continue;
|
||||
}
|
||||
for candidate in &candidates {
|
||||
let path = base.join(candidate);
|
||||
if path.is_file() {
|
||||
if load_wordlist(&path, &mut words) {
|
||||
found_any = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if found_any {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let has_cli = is_command_in_path("hunspell") || is_command_in_path("aspell");
|
||||
|
||||
if !found_any && !has_cli {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
words,
|
||||
language: lang_tag.to_string(),
|
||||
has_cli_tool: has_cli,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_available(&self) -> bool {
|
||||
!self.words.is_empty() || self.has_cli_tool
|
||||
}
|
||||
|
||||
pub fn language(&self) -> &str {
|
||||
&self.language
|
||||
}
|
||||
|
||||
pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec<String>) {
|
||||
let clean = word.trim().to_lowercase();
|
||||
if clean.is_empty() {
|
||||
return (true, Vec::new());
|
||||
}
|
||||
|
||||
if self.words.contains(&clean) {
|
||||
return (true, Vec::new());
|
||||
}
|
||||
|
||||
// If CLI hunspell is available, fallback to pipe check
|
||||
if self.has_cli_tool {
|
||||
if let Some((valid, suggs)) = check_via_cli(word, &self.language, want_suggestions) {
|
||||
return (valid, suggs);
|
||||
}
|
||||
}
|
||||
|
||||
(!self.words.is_empty() && !self.words.contains(&clean), Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn load_wordlist(path: &Path, words: &mut HashSet<String>) -> bool {
|
||||
let Ok(file) = File::open(path) else { return false };
|
||||
let reader = BufReader::new(file);
|
||||
for line in reader.lines().flatten() {
|
||||
let entry = line.trim();
|
||||
if entry.is_empty() || entry.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
// Strip Hunspell flags: "word/flags"
|
||||
let word_part = entry.split('/').next().unwrap_or(entry).trim();
|
||||
if !word_part.is_empty() && !word_part.chars().all(|c| c.is_ascii_digit()) {
|
||||
words.insert(word_part.to_lowercase());
|
||||
}
|
||||
}
|
||||
!words.is_empty()
|
||||
}
|
||||
|
||||
fn is_command_in_path(cmd: &str) -> bool {
|
||||
if let Ok(path_var) = std::env::var("PATH") {
|
||||
for dir in std::env::split_paths(&path_var) {
|
||||
if dir.join(cmd).is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn check_via_cli(word: &str, lang: &str, want_suggestions: bool) -> Option<(bool, Vec<String>)> {
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let lang_param = lang.replace('-', "_");
|
||||
let mut child = Command::new("hunspell")
|
||||
.arg("-d")
|
||||
.arg(&lang_param)
|
||||
.arg("-a")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let _ = writeln!(stdin, "^{word}");
|
||||
}
|
||||
|
||||
let output = child.wait_with_output().ok()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
if line.starts_with('*') || line.starts_with('+') || line.starts_with('-') {
|
||||
return Some((true, Vec::new()));
|
||||
}
|
||||
if line.starts_with('&') {
|
||||
let mut suggs = Vec::new();
|
||||
if want_suggestions {
|
||||
if let Some(pos) = line.find(':') {
|
||||
for s in line[pos + 1..].split(',') {
|
||||
let item = s.trim();
|
||||
if !item.is_empty() && suggs.len() < 5 {
|
||||
suggs.push(item.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Some((false, suggs));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fallback Backend
|
||||
// ============================================================================
|
||||
mod fallback_impl {
|
||||
pub struct FallbackSpeller;
|
||||
}
|
||||
+101
-13
@@ -955,6 +955,20 @@ impl OpenCADStudio {
|
||||
.map(|w| TextMatcher::new(w.clone(), false, true, true))
|
||||
.collect();
|
||||
|
||||
let use_system_speller = req["system_spellcheck"]
|
||||
.as_bool()
|
||||
.or_else(|| req["use_system_speller"].as_bool())
|
||||
.or_else(|| req["system_speller"].as_bool())
|
||||
.unwrap_or(false);
|
||||
let language_param = req["language"].as_str();
|
||||
let want_suggestions = req["suggest"].as_bool().unwrap_or(true);
|
||||
|
||||
let system_speller = if use_system_speller {
|
||||
Some(super::spellcheck::SystemSpeller::new(language_param))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut pair_hit_counts = vec![0usize; pair_matchers.len()];
|
||||
let mut check_hit_counts = vec![0usize; check_matchers.len()];
|
||||
|
||||
@@ -1090,21 +1104,39 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Dictionary check
|
||||
if !dict_matchers.is_empty() {
|
||||
// 3. Dictionary & System Spell-check
|
||||
if !dict_matchers.is_empty() || system_speller.is_some() {
|
||||
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,
|
||||
}));
|
||||
if w.len() >= 3 {
|
||||
let in_agent_dict = dict_matchers.iter().any(|dm| dm.matches(w));
|
||||
if in_agent_dict {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (is_system_valid, suggestions) = if let Some(ref speller) = system_speller {
|
||||
speller.check_word(w, want_suggestions)
|
||||
} else {
|
||||
(false, Vec::new())
|
||||
};
|
||||
|
||||
if !is_system_valid {
|
||||
entity_had_match = true;
|
||||
if unrecognized_words.len() < limit
|
||||
&& !unrecognized_words
|
||||
.iter()
|
||||
.any(|item: &Value| item["word"].as_str() == Some(w))
|
||||
{
|
||||
let mut item = json!({
|
||||
"word": w,
|
||||
"handle": format!("{:X}", handle.value()),
|
||||
"position": pos,
|
||||
});
|
||||
if !suggestions.is_empty() {
|
||||
item["suggestions"] = json!(suggestions);
|
||||
}
|
||||
unrecognized_words.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1157,6 +1189,44 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !dict_matchers.is_empty() || system_speller.is_some() {
|
||||
for word in plain.split(|c: char| !c.is_alphabetic()) {
|
||||
let w = word.trim();
|
||||
if w.len() >= 3 {
|
||||
let in_agent_dict = dict_matchers.iter().any(|dm| dm.matches(w));
|
||||
if in_agent_dict {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (is_system_valid, suggestions) = if let Some(ref speller) = system_speller {
|
||||
speller.check_word(w, want_suggestions)
|
||||
} else {
|
||||
(false, Vec::new())
|
||||
};
|
||||
|
||||
if !is_system_valid {
|
||||
entity_had_match = true;
|
||||
if unrecognized_words.len() < limit
|
||||
&& !unrecognized_words
|
||||
.iter()
|
||||
.any(|item: &Value| item["word"].as_str() == Some(w))
|
||||
{
|
||||
let mut item = json!({
|
||||
"word": w,
|
||||
"handle": format!("{:X}", handle.value()),
|
||||
"attribute_tag": attr.tag,
|
||||
"position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z],
|
||||
});
|
||||
if !suggestions.is_empty() {
|
||||
item["suggestions"] = json!(suggestions);
|
||||
}
|
||||
unrecognized_words.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1180,6 +1250,22 @@ impl OpenCADStudio {
|
||||
.map(|(idx, _)| check_terms[idx].clone())
|
||||
.collect();
|
||||
|
||||
let speller_info = if let Some(ref speller) = system_speller {
|
||||
json!({
|
||||
"enabled": true,
|
||||
"available": speller.is_available(),
|
||||
"backend": speller.backend_name(),
|
||||
"language": speller.language(),
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"enabled": false,
|
||||
"available": false,
|
||||
"backend": "none",
|
||||
"language": null,
|
||||
})
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"summary": {
|
||||
@@ -1190,7 +1276,9 @@ impl OpenCADStudio {
|
||||
"pairs_unmatched": unmatched_pairs.len(),
|
||||
"check_terms_total": check_terms.len(),
|
||||
"check_terms_found": check_terms.len() - unmatched_check_terms.len(),
|
||||
"unrecognized_words_count": unrecognized_words.len(),
|
||||
},
|
||||
"system_speller": speller_info,
|
||||
"unmatched_pairs": unmatched_pairs,
|
||||
"unmatched_check_terms": unmatched_check_terms,
|
||||
"simulated_changes": simulated_changes,
|
||||
|
||||
+1
-1
@@ -1225,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."},"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},
|
||||
"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."},"system_spellcheck":{"type":"boolean","default":false,"description":"Use native OS spell-checker (Windows, macOS, Linux)."},"language":{"type":"string","description":"Language tag for spell-checker (e.g. 'fr-FR', 'en-US', 'de-DE', 'es-ES')."},"suggest":{"type":"boolean","default":true,"description":"Include suggested corrections for misspelled words."},"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}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user