forked from 1b-agent/opencadstudio-upstream
feat(plugin): name-addressed XRecord CRUD on the HostApi (API v8)
Add create/read/update/delete/list for XRecord objects under named-object-dictionary paths to the plugin HostApi, with five additive PluginRequest/PluginResponse variants, dispatch through the shared handler, in-process HostSession methods and both out-of-process proxies. - dictionary paths walk from the NOD root; create seeds missing dictionaries (ensure_image_dictionary bootstrap) and refuses existing names; update refuses missing ones; refusals precede the undo step - entry group codes validated (1..=369, never 5/105); payload content is the plugin's own - each mutating call is one undo step: the pending history snapshot restores the objects map byte-exactly, auto-created dictionaries included (tested) - typed entries survive DWG and DXF write/reload round-trips (tested); update drops verbatim raw bytes so saves emit the new content - bump API_VERSION 7 -> 8 with the build.rs version_info in lockstep, mirroring the v7 addition; MIN_SUPPORTED stays 3
This commit is contained in:
1 parent
6cde0e02d8
commit
ca2f0c9857
11 files changed
+1212
-3
No files matched your search
@@ -1263,7 +1263,7 @@ fn generate_version_info(out_dir: &Path) {
|
||||
"acadrust_version": opencadcodec.version.to_string(),
|
||||
"acadrust_source": opencadcodec.source.as_ref().map(|s| s.to_string()),
|
||||
"rustc_version": rustc_version,
|
||||
"api_version": 7,
|
||||
"api_version": 8,
|
||||
"api_version_min_supported": 3,
|
||||
"build_timestamp": build_timestamp,
|
||||
});
|
||||
|
||||
@@ -1085,6 +1085,85 @@ pub trait HostApi {
|
||||
let _ = cmd;
|
||||
false
|
||||
}
|
||||
|
||||
// ── XRecord objects (added in API v8; appended at the end to keep vtable
|
||||
// indices stable for older plugins) ────────────────────────────────────────
|
||||
|
||||
/// Create an XRecord named `name` under the dictionary addressed by
|
||||
/// `dictionary_path`, storing `entries` (API v8, additive).
|
||||
///
|
||||
/// The path is walked from the drawing's named-object dictionary root: an
|
||||
/// empty path addresses the root itself, and missing intermediate
|
||||
/// dictionaries are created, so the first call seeds a plugin's namespace.
|
||||
/// Every refusal (empty or invalid names, a path fragment that names a
|
||||
/// non-dictionary object, a name already in use — comparison is
|
||||
/// case-insensitive like dictionary lookups) happens **before** any change,
|
||||
/// so a failure leaves the drawing untouched. The record is registered in
|
||||
/// its dictionary and owned by it; the returned handle stays stable across
|
||||
/// updates. The change is one undo step.
|
||||
fn create_xrecord(
|
||||
&mut self,
|
||||
_dictionary_path: &[&str],
|
||||
_name: &str,
|
||||
_entries: Vec<XRecordEntry>,
|
||||
) -> Result<Handle, String> {
|
||||
Err("XRecord creation is not supported by this host".to_owned())
|
||||
}
|
||||
|
||||
/// Read the entries of an existing XRecord by name (API v8, additive).
|
||||
///
|
||||
/// Resolves `dictionary_path` under the named-object dictionary root and
|
||||
/// returns the record's entries verbatim. Fails when the path, the name or
|
||||
/// the record is missing, or when the name addresses a different object
|
||||
/// kind.
|
||||
fn read_xrecord(
|
||||
&self,
|
||||
_dictionary_path: &[&str],
|
||||
_name: &str,
|
||||
) -> Result<Vec<XRecordEntry>, String> {
|
||||
Err("XRecord reads are not supported by this host".to_owned())
|
||||
}
|
||||
|
||||
/// Atomically replace the entries of an existing XRecord (API v8,
|
||||
/// additive). Returns the record's handle.
|
||||
///
|
||||
/// Neither the path nor its dictionaries are created: an update must never
|
||||
/// create anything silently. Identity (handle, owning dictionary) stays;
|
||||
/// the entries and their DWG handle-reference stream are replaced and the
|
||||
/// record's verbatim raw payloads are dropped, so subsequent saves emit the
|
||||
/// new content. A refusal leaves the drawing untouched; a successful change
|
||||
/// is one undo step whose undo restores the previous entries byte-exactly.
|
||||
fn update_xrecord(
|
||||
&mut self,
|
||||
_dictionary_path: &[&str],
|
||||
_name: &str,
|
||||
_entries: Vec<XRecordEntry>,
|
||||
) -> Result<Handle, String> {
|
||||
Err("XRecord updates are not supported by this host".to_owned())
|
||||
}
|
||||
|
||||
/// Delete the XRecord named `name` and its dictionary entry (API v8,
|
||||
/// additive). Returns the removed record, so audits keep the prior
|
||||
/// content. A refusal leaves the drawing untouched; a successful delete is
|
||||
/// one undo step.
|
||||
fn delete_xrecord(
|
||||
&mut self,
|
||||
_dictionary_path: &[&str],
|
||||
_name: &str,
|
||||
) -> Result<XRecord, String> {
|
||||
Err("XRecord deletes are not supported by this host".to_owned())
|
||||
}
|
||||
|
||||
/// List the XRecord names under the dictionary addressed by
|
||||
/// `dictionary_path` (API v8, additive).
|
||||
///
|
||||
/// Returns only the dictionary keys that address XRecord objects, in the
|
||||
/// dictionary's own order, so consumers can audit and export without
|
||||
/// knowing names in advance. Fails when the path does not address a
|
||||
/// dictionary.
|
||||
fn list_xrecords(&self, _dictionary_path: &[&str]) -> Result<Vec<String>, String> {
|
||||
Err("XRecord listing is not supported by this host".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration properties for creating or modifying a layer.
|
||||
|
||||
@@ -603,6 +603,95 @@ impl HostApi for PluginHostApi {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
entries: Vec<crate::host::XRecordEntry>,
|
||||
) -> Result<Handle, String> {
|
||||
match self.client.request(PluginRequest::CreateXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
entries,
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordCreateResult(result)) => {
|
||||
if result.is_ok() {
|
||||
self.document_cache = OnceCell::new();
|
||||
}
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord create response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_xrecord(
|
||||
&self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
) -> Result<Vec<crate::host::XRecordEntry>, String> {
|
||||
match self.client.request(PluginRequest::ReadXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordEntries(result)) => result,
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord read response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
entries: Vec<crate::host::XRecordEntry>,
|
||||
) -> Result<Handle, String> {
|
||||
match self.client.request(PluginRequest::UpdateXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
entries,
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordUpdateResult(result)) => {
|
||||
if result.is_ok() {
|
||||
self.document_cache = OnceCell::new();
|
||||
}
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord update response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
) -> Result<crate::host::XRecord, String> {
|
||||
match self.client.request(PluginRequest::DeleteXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordDeleteResult(result)) => result,
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord delete response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
|
||||
match self.client.request(PluginRequest::ListXRecords {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordNames(result)) => result,
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord list response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentinel reader used when the shared-memory view could not be initialized.
|
||||
@@ -810,4 +899,68 @@ mod tests {
|
||||
assert!(api.remove_entity(Handle::new(7)));
|
||||
peer_handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xrecord_requests_await_their_typed_responses() {
|
||||
let (mut api, mut peer) = make_client();
|
||||
let peer_handle = thread::spawn(move || {
|
||||
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
||||
match msg {
|
||||
PluginToHost::Request(req) => match *req {
|
||||
PluginRequest::CreateXRecord {
|
||||
dictionary_path,
|
||||
name,
|
||||
entries,
|
||||
} => {
|
||||
assert_eq!(dictionary_path, vec!["CVIFC".to_owned()]);
|
||||
assert_eq!(name, "alpha");
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}")]
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
send(
|
||||
&mut peer,
|
||||
&HostToPlugin::Response(Box::new(PluginResponse::XRecordCreateResult(Ok(
|
||||
Handle::new(21),
|
||||
)))),
|
||||
)
|
||||
.unwrap();
|
||||
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
||||
match msg {
|
||||
PluginToHost::Request(req) => match *req {
|
||||
PluginRequest::ListXRecords { dictionary_path } => {
|
||||
assert_eq!(dictionary_path, vec!["CVIFC".to_owned()]);
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
send(
|
||||
&mut peer,
|
||||
&HostToPlugin::Response(Box::new(PluginResponse::XRecordNames(Ok(vec![
|
||||
"alpha".to_owned()
|
||||
])))),
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
assert_eq!(
|
||||
api.create_xrecord(
|
||||
&["CVIFC"],
|
||||
"alpha",
|
||||
vec![crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}")]
|
||||
)
|
||||
.unwrap(),
|
||||
Handle::new(21)
|
||||
);
|
||||
assert_eq!(
|
||||
api.list_xrecords(&["CVIFC"]).unwrap(),
|
||||
vec!["alpha".to_owned()]
|
||||
);
|
||||
peer_handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,32 @@ pub enum PluginRequest {
|
||||
RunCommand {
|
||||
request: crate::host::CommandRequest,
|
||||
},
|
||||
/// V8 (additive): create an XRecord under a named-object-dictionary path.
|
||||
CreateXRecord {
|
||||
dictionary_path: Vec<String>,
|
||||
name: String,
|
||||
entries: Vec<crate::host::XRecordEntry>,
|
||||
},
|
||||
/// V8 (additive): read the entries of an existing XRecord.
|
||||
ReadXRecord {
|
||||
dictionary_path: Vec<String>,
|
||||
name: String,
|
||||
},
|
||||
/// V8 (additive): replace the entries of an existing XRecord.
|
||||
UpdateXRecord {
|
||||
dictionary_path: Vec<String>,
|
||||
name: String,
|
||||
entries: Vec<crate::host::XRecordEntry>,
|
||||
},
|
||||
/// V8 (additive): delete an XRecord and its dictionary entry.
|
||||
DeleteXRecord {
|
||||
dictionary_path: Vec<String>,
|
||||
name: String,
|
||||
},
|
||||
/// V8 (additive): list the XRecord names under a dictionary path.
|
||||
ListXRecords {
|
||||
dictionary_path: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Responses the host sends back for `PluginRequest`.
|
||||
@@ -236,6 +262,16 @@ pub enum PluginResponse {
|
||||
SolidResult(Result<Handle, String>),
|
||||
TableResult(Result<Handle, String>),
|
||||
CommandResult(Result<crate::host::CommandOutcome, String>),
|
||||
/// V8 (additive): handle of a created XRecord.
|
||||
XRecordCreateResult(Result<Handle, String>),
|
||||
/// V8 (additive): entries of a read XRecord.
|
||||
XRecordEntries(Result<Vec<crate::host::XRecordEntry>, String>),
|
||||
/// V8 (additive): handle of an updated XRecord.
|
||||
XRecordUpdateResult(Result<Handle, String>),
|
||||
/// V8 (additive): the removed XRecord, with its prior content.
|
||||
XRecordDeleteResult(Result<crate::host::XRecord, String>),
|
||||
/// V8 (additive): XRecord names under a dictionary path.
|
||||
XRecordNames(Result<Vec<String>, String>),
|
||||
}
|
||||
|
||||
/// Messages sent from the host to the plugin runner.
|
||||
|
||||
@@ -117,5 +117,39 @@ pub fn handle_plugin_request(
|
||||
PluginResponse::TableResult(host.table_operation(operation))
|
||||
}
|
||||
RunCommand { request } => PluginResponse::CommandResult(host.run_command(request)),
|
||||
CreateXRecord {
|
||||
dictionary_path,
|
||||
name,
|
||||
entries,
|
||||
} => {
|
||||
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
|
||||
PluginResponse::XRecordCreateResult(host.create_xrecord(&path, &name, entries))
|
||||
}
|
||||
ReadXRecord {
|
||||
dictionary_path,
|
||||
name,
|
||||
} => {
|
||||
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
|
||||
PluginResponse::XRecordEntries(host.read_xrecord(&path, &name))
|
||||
}
|
||||
UpdateXRecord {
|
||||
dictionary_path,
|
||||
name,
|
||||
entries,
|
||||
} => {
|
||||
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
|
||||
PluginResponse::XRecordUpdateResult(host.update_xrecord(&path, &name, entries))
|
||||
}
|
||||
DeleteXRecord {
|
||||
dictionary_path,
|
||||
name,
|
||||
} => {
|
||||
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
|
||||
PluginResponse::XRecordDeleteResult(host.delete_xrecord(&path, &name))
|
||||
}
|
||||
ListXRecords { dictionary_path } => {
|
||||
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
|
||||
PluginResponse::XRecordNames(host.list_xrecords(&path))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -898,6 +898,95 @@ impl HostApi for V4PluginHostApi {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
entries: Vec<crate::host::XRecordEntry>,
|
||||
) -> Result<Handle, String> {
|
||||
match self.request(PluginRequest::CreateXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
entries,
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordCreateResult(result)) => {
|
||||
if result.is_ok() {
|
||||
self.document_cache = OnceCell::new();
|
||||
}
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord create response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_xrecord(
|
||||
&self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
) -> Result<Vec<crate::host::XRecordEntry>, String> {
|
||||
match self.request(PluginRequest::ReadXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordEntries(result)) => result,
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord read response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
entries: Vec<crate::host::XRecordEntry>,
|
||||
) -> Result<Handle, String> {
|
||||
match self.request(PluginRequest::UpdateXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
entries,
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordUpdateResult(result)) => {
|
||||
if result.is_ok() {
|
||||
self.document_cache = OnceCell::new();
|
||||
}
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord update response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
) -> Result<crate::host::XRecord, String> {
|
||||
match self.request(PluginRequest::DeleteXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordDeleteResult(result)) => result,
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord delete response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
|
||||
match self.request(PluginRequest::ListXRecords {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordNames(result)) => result,
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord list response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -388,6 +388,111 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xrecord_request_and_response_roundtrip() {
|
||||
// The five XRecord request variants (API v8) serialize with their
|
||||
// dictionary paths and typed entries, and the responses answer with
|
||||
// handles, entries, records and names.
|
||||
let entries = vec![
|
||||
crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}"),
|
||||
crate::host::XRecordEntry::int16(70, 3),
|
||||
];
|
||||
let bytes = bincode::serialize(&PluginRequest::CreateXRecord {
|
||||
dictionary_path: vec!["CVIFC".to_owned()],
|
||||
name: "alpha".to_owned(),
|
||||
entries: entries.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
PluginRequest::CreateXRecord { dictionary_path, name, entries: decoded } if
|
||||
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha" && decoded == entries)
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginRequest::ReadXRecord {
|
||||
dictionary_path: vec!["CVIFC".to_owned()],
|
||||
name: "alpha".to_owned(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
PluginRequest::ReadXRecord { dictionary_path, name } if
|
||||
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha")
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginRequest::UpdateXRecord {
|
||||
dictionary_path: vec!["CVIFC".to_owned()],
|
||||
name: "alpha".to_owned(),
|
||||
entries: entries.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
PluginRequest::UpdateXRecord { dictionary_path, name, entries: decoded } if
|
||||
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha" && decoded == entries)
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginRequest::DeleteXRecord {
|
||||
dictionary_path: vec!["CVIFC".to_owned()],
|
||||
name: "alpha".to_owned(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
PluginRequest::DeleteXRecord { dictionary_path, name } if
|
||||
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha")
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginRequest::ListXRecords {
|
||||
dictionary_path: vec!["CVIFC".to_owned()],
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
PluginRequest::ListXRecords { dictionary_path } if
|
||||
dictionary_path == vec!["CVIFC".to_owned()])
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginResponse::XRecordCreateResult(Ok(
|
||||
codec::Handle::new(42),
|
||||
)))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::XRecordCreateResult(Ok(handle)) if handle == codec::Handle::new(42))
|
||||
);
|
||||
let bytes =
|
||||
bincode::serialize(&PluginResponse::XRecordEntries(Ok(entries.clone()))).unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::XRecordEntries(Ok(decoded)) if decoded == entries)
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginResponse::XRecordUpdateResult(Ok(
|
||||
codec::Handle::new(42),
|
||||
)))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::XRecordUpdateResult(Ok(handle)) if handle == codec::Handle::new(42))
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginResponse::XRecordNames(Ok(vec!["alpha".to_owned()])))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::XRecordNames(Ok(names)) if names == vec!["alpha".to_owned()])
|
||||
);
|
||||
let deleted = crate::host::XRecord::named("alpha");
|
||||
let bytes =
|
||||
bincode::serialize(&PluginResponse::XRecordDeleteResult(Ok(deleted.clone()))).unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::XRecordDeleteResult(Ok(record)) if record == deleted)
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginResponse::XRecordEntries(Err(
|
||||
"XRecord reads are not supported by this host".to_owned(),
|
||||
)))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::XRecordEntries(Err(error)) if error.contains("not supported"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_notification_roundtrip() {
|
||||
let frame = PluginToHostV4::Notification(NotificationEnvelope {
|
||||
|
||||
@@ -21,8 +21,11 @@
|
||||
/// adds the `BuiltinPlugin::on_load` lifecycle callback and
|
||||
/// `HostApi::document_path`. v6 appends generic host system-variable access
|
||||
/// (`HostApi::system_variable` / `set_system_variable`). v7 appends atomic,
|
||||
/// undoable replacement of a set of existing entities.
|
||||
pub const API_VERSION: u32 = 7;
|
||||
/// undoable replacement of a set of existing entities. v8 appends name-addressed
|
||||
/// XRecord object CRUD under named-object-dictionary paths
|
||||
/// (`HostApi::create_xrecord` / `read_xrecord` / `update_xrecord` /
|
||||
/// `delete_xrecord` / `list_xrecords`).
|
||||
pub const API_VERSION: u32 = 8;
|
||||
|
||||
/// Oldest plugin API major the current host still loads. This keeps previously
|
||||
/// compiled cdylibs usable as long as their vtable layout is a prefix of the
|
||||
|
||||
@@ -339,3 +339,21 @@ plugin gate (60 real-IPC host tests, 115 plugin API, 20 plugin crate, 16 Python
|
||||
`--no-fail-fast` (1,805 passed, 1 failed: `fonts_parse_and_resolve`, which fails on pristine `main` on this Mac too; `arc_grips_drive_center_start_and_end_but_not_midpoint`
|
||||
now passes, fixed upstream). The change is 72 files, +30,705 / -178 against current `main` (fork-only workflow excluded).
|
||||
|
||||
### 2026-10-11: XRecord objects (API v8, no Python surface yet)
|
||||
|
||||
New additive v8 requests `CreateXRecord` / `ReadXRecord` / `UpdateXRecord` / `DeleteXRecord` / `ListXRecords`
|
||||
give plugins a non-graphical record carrier: name-addressed typed `XRecordEntry` pairs under the drawing's
|
||||
named-object dictionary, with no carrier entity. `dictionary_path` is walked from the NOD root (an empty path
|
||||
is the root), creating missing dictionaries on create only — the `ensure_image_dictionary` bootstrap — so the
|
||||
first call seeds a namespace; `create` refuses an existing name and `update` refuses a missing one, so neither
|
||||
can silently overwrite or create. Names and path fragments are symbol-validated, entry group codes are checked
|
||||
(1..=369, never the handle codes 5/105), and every refusal is decided before the undo step is recorded. Update
|
||||
replaces the entries atomically (handle, owner and name stay, the record's verbatim raw payloads are dropped, so
|
||||
saves emit the new content); delete returns the removed record; list reports only the keys that address
|
||||
XRecords, in dictionary order. Undo: objects-map mutations are captured by the pending history's full structure
|
||||
snapshot, so each mutating call is one undo step whose undo restores the objects map byte-exactly, the
|
||||
auto-created dictionaries included. Evidence: `xrecord_crud_round_trips_refusals_and_list`,
|
||||
`xrecord_mutations_are_single_undo_steps_that_restore_objects` (four steps, refusal paths record none),
|
||||
`xrecords_survive_dxf_and_dwg_round_trips` (typed entries reload from both writers), the IPC round-trips
|
||||
`xrecord_request_and_response_roundtrip` and `xrecord_requests_await_their_typed_responses`. The API is
|
||||
host-level and stands on its own; a Python mapping (`doc.xrecords`-style) is future work.
|
||||
@@ -26,6 +26,7 @@ without it keeps working, and the default `HostApi` methods refuse.
|
||||
| Selection | `selection`, `set_selection` | Ordered, tab-scoped, exact (missing or duplicate handles reject the request). |
|
||||
| Solids | `solid_operation` | Kernel-backed create, transform, booleans, extrude, region and surface from a profile, picture embedding. Every result is verified to lift back from its ACIS payload without loss; a refusal leaves the operands untouched. |
|
||||
| Tables | `table_operation` | Layers, text and dimension styles, blocks and their contents, linetypes, layouts (see below). |
|
||||
| Objects | `create_xrecord`, `read_xrecord`, `update_xrecord`, `delete_xrecord`, `list_xrecords` | Name-addressed XRecords under the drawing's named-object dictionary (API v8, see below): a non-graphical record carrier with strict create-vs-update semantics and one undo step per change. |
|
||||
| Commands | `run_command` | Drives the real OCS command one step at a time (see below). |
|
||||
| Settings | `system_variable`, `set_system_variable` | `CLAYER`, `SNAPANG`, and a read-only `CTAB` (the current layout). |
|
||||
|
||||
@@ -85,6 +86,22 @@ recorded, so a failure changes nothing and leaves the history alone.
|
||||
Style and layout operations act on the active document, because they use the
|
||||
application's shared style and view machinery.
|
||||
|
||||
## XRecords (API v8)
|
||||
|
||||
`create_xrecord`, `read_xrecord`, `update_xrecord`, `delete_xrecord` and
|
||||
`list_xrecords` address XRecords by name under a dictionary path walked from the
|
||||
named-object dictionary root (an empty path is the root itself); missing
|
||||
dictionaries are created on create only, so the first call seeds a plugin's
|
||||
namespace. The payload is the codec's typed `XRecordEntry` pairs — the host puts
|
||||
no opinion on the content. Names and path fragments are symbol-validated, and
|
||||
dictionary addressing follows the codec's case-insensitive lookup. Create never
|
||||
overwrites and update never creates; each refusal is decided before the undo step
|
||||
is recorded, so a failure changes nothing, and each change is one undo step whose
|
||||
undo restores the objects map byte-exactly, the auto-created dictionaries
|
||||
included. `list_xrecords` reports only the keys that address XRecords, in the
|
||||
dictionary's own order, so audits need no prior knowledge. Not yet mapped to
|
||||
Python.
|
||||
|
||||
## Commands
|
||||
|
||||
`run_command` takes a `CommandRequest` (`Run`, `Start`, `Point`, `Text`, `Token`,
|
||||
|
||||
@@ -2777,6 +2777,324 @@ impl<'a> HostSession<'a> {
|
||||
self.publish_document_view();
|
||||
true
|
||||
}
|
||||
|
||||
// ── XRecord objects (API v8, additive) ────────────────────────────────────
|
||||
//
|
||||
// XRecords are non-graphical objects: name-addressed typed group-code/value
|
||||
// pairs stored under the drawing's named-object dictionary, like AutoCAD
|
||||
// keeps application data. They give scripts a record carrier that lives in
|
||||
// the file and survives model view entirely, with no carrier entity.
|
||||
|
||||
/// The dictionary addressed by `dictionary_path` under the named-object
|
||||
/// dictionary root (an empty path is the root itself). Nothing is created:
|
||||
/// a missing fragment is an error, and so is a fragment that exists but
|
||||
/// names a non-dictionary object.
|
||||
fn xrecord_dictionary_at(&self, dictionary_path: &[&str]) -> Result<Handle, String> {
|
||||
use codec::objects::ObjectType;
|
||||
let root = self.document().header.named_objects_dict_handle;
|
||||
if !matches!(
|
||||
self.document().objects.get(&root),
|
||||
Some(ObjectType::Dictionary(_))
|
||||
) {
|
||||
return Err("the drawing has no named-object dictionary".to_owned());
|
||||
}
|
||||
let mut current = root;
|
||||
for fragment in dictionary_path {
|
||||
let name = validated_symbol_name(fragment, "a dictionary name")?;
|
||||
match self.document().objects.get(¤t) {
|
||||
Some(ObjectType::Dictionary(dictionary)) => match dictionary.get(&name) {
|
||||
Some(child) => {
|
||||
if !matches!(
|
||||
self.document().objects.get(&child),
|
||||
Some(ObjectType::Dictionary(_))
|
||||
) {
|
||||
return Err(format!("dictionary entry {name:?} is not a dictionary"));
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
None => return Err(format!("dictionary {name:?} does not exist")),
|
||||
},
|
||||
_ => return Err(format!("dictionary {current:?} does not exist")),
|
||||
}
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
/// The deepest ancestor of `dictionary_path` that already exists, and how
|
||||
/// deep the path resolved. Errors when a fragment that exists names a
|
||||
/// non-dictionary object. Creation calls this first so its refusals happen
|
||||
/// before the undo step is recorded.
|
||||
fn deepest_existing_xrecord_dictionary(
|
||||
&self,
|
||||
dictionary_path: &[&str],
|
||||
) -> Result<(Handle, usize), String> {
|
||||
use codec::objects::ObjectType;
|
||||
let root = self.document().header.named_objects_dict_handle;
|
||||
if !matches!(
|
||||
self.document().objects.get(&root),
|
||||
Some(ObjectType::Dictionary(_))
|
||||
) {
|
||||
return Err("the drawing has no named-object dictionary".to_owned());
|
||||
}
|
||||
let mut current = root;
|
||||
let mut depth = 0usize;
|
||||
for fragment in dictionary_path {
|
||||
let name = validated_symbol_name(fragment, "a dictionary name")?;
|
||||
match self.document().objects.get(¤t) {
|
||||
Some(ObjectType::Dictionary(dictionary)) => match dictionary.get(&name) {
|
||||
Some(child) => {
|
||||
if !matches!(
|
||||
self.document().objects.get(&child),
|
||||
Some(ObjectType::Dictionary(_))
|
||||
) {
|
||||
return Err(format!("dictionary entry {name:?} is not a dictionary"));
|
||||
}
|
||||
current = child;
|
||||
depth += 1;
|
||||
}
|
||||
None => break,
|
||||
},
|
||||
_ => return Err(format!("dictionary {current:?} does not exist")),
|
||||
}
|
||||
}
|
||||
Ok((current, depth))
|
||||
}
|
||||
|
||||
/// Walk `dictionary_path` from the named-object dictionary root, creating
|
||||
/// missing dictionaries on the way — the same bootstrap
|
||||
/// `ensure_image_dictionary` uses for ACAD_IMAGE_DICT: each new dictionary
|
||||
/// owns its handle, is owned by its parent and is registered in the parent
|
||||
/// under its name. Mutates; only valid after the caller validated the path
|
||||
/// and recorded its undo step.
|
||||
fn xrecord_dictionary_at_or_create(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
) -> Result<Handle, String> {
|
||||
use codec::objects::{Dictionary, ObjectType};
|
||||
let root = self.document().header.named_objects_dict_handle;
|
||||
if !matches!(
|
||||
self.document().objects.get(&root),
|
||||
Some(ObjectType::Dictionary(_))
|
||||
) {
|
||||
return Err("the drawing has no named-object dictionary".to_owned());
|
||||
}
|
||||
let mut current = root;
|
||||
for fragment in dictionary_path {
|
||||
let name = validated_symbol_name(fragment, "a dictionary name")?;
|
||||
let existing = match self.document().objects.get(¤t) {
|
||||
Some(ObjectType::Dictionary(dictionary)) => dictionary.get(&name),
|
||||
_ => return Err(format!("dictionary {current:?} does not exist")),
|
||||
};
|
||||
match existing {
|
||||
Some(child) => {
|
||||
if !matches!(
|
||||
self.document().objects.get(&child),
|
||||
Some(ObjectType::Dictionary(_))
|
||||
) {
|
||||
return Err(format!("dictionary entry {name:?} is not a dictionary"));
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
None => {
|
||||
let child = self.document_mut().allocate_handle();
|
||||
let mut dictionary = Dictionary::new();
|
||||
dictionary.handle = child;
|
||||
dictionary.owner = current;
|
||||
self.document_mut()
|
||||
.objects
|
||||
.insert(child, ObjectType::Dictionary(dictionary));
|
||||
if let Some(ObjectType::Dictionary(entries)) =
|
||||
self.document_mut().objects.get_mut(¤t)
|
||||
{
|
||||
entries.add_entry(name, child);
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
/// Create an XRecord named `name` under `dictionary_path`, storing
|
||||
/// `entries` (API v8, additive). Missing dictionaries on the path are
|
||||
/// created, so the first call seeds a plugin's namespace; the name must be
|
||||
/// unused (dictionary lookup is case-insensitive). Every refusal happens
|
||||
/// before the undo step is recorded, so a failure changes nothing; the
|
||||
/// created record is registered in — and owned by — its dictionary, and
|
||||
/// the new handle is returned.
|
||||
pub fn create_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
entries: Vec<codec::objects::XRecordEntry>,
|
||||
) -> Result<Handle, String> {
|
||||
use codec::objects::{ObjectType, XRecord};
|
||||
let name = validated_symbol_name(name, "an XRecord name")?;
|
||||
validated_xrecord_entries(&entries)?;
|
||||
// Pre-check the path: fragments that already exist must be
|
||||
// dictionaries, and when the full path exists the name must be free —
|
||||
// create must never silently overwrite a key.
|
||||
let (deepest, depth) = self.deepest_existing_xrecord_dictionary(dictionary_path)?;
|
||||
if depth == dictionary_path.len() {
|
||||
if let Some(ObjectType::Dictionary(dictionary)) = self.document().objects.get(&deepest)
|
||||
{
|
||||
if dictionary.get(&name).is_some() {
|
||||
return Err(format!("XRecord {name:?} already exists"));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.push_undo("Create XRecord");
|
||||
// The pre-check ruled out every failure mode; the walk now only creates
|
||||
// the dictionary fragments that were missing.
|
||||
let dictionary = self
|
||||
.xrecord_dictionary_at_or_create(dictionary_path)
|
||||
.expect("validated dictionary path");
|
||||
let handle = self.document_mut().allocate_handle();
|
||||
let mut record = XRecord::named(name.clone());
|
||||
record.handle = handle;
|
||||
record.owner = dictionary;
|
||||
record.entries = entries;
|
||||
record.synchronize_object_references();
|
||||
self.document_mut()
|
||||
.objects
|
||||
.insert(handle, ObjectType::XRecord(record));
|
||||
if let Some(ObjectType::Dictionary(entries)) =
|
||||
self.document_mut().objects.get_mut(&dictionary)
|
||||
{
|
||||
entries.add_entry(name, handle);
|
||||
}
|
||||
self.set_dirty();
|
||||
self.publish_document_view();
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Read the entries of an existing XRecord by name (API v8, additive).
|
||||
/// The entries are returned verbatim; the path, the name and the target
|
||||
/// kind are validated first, so a refusal changes nothing.
|
||||
pub fn read_xrecord(
|
||||
&self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
) -> Result<Vec<codec::objects::XRecordEntry>, String> {
|
||||
use codec::objects::ObjectType;
|
||||
let (_, _, handle) = self.xrecord_lookup(dictionary_path, name)?;
|
||||
match self.document().objects.get(&handle) {
|
||||
Some(ObjectType::XRecord(record)) => Ok(record.entries.clone()),
|
||||
_ => unreachable!("validated XRecord"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically replace the entries of an existing XRecord (API v8,
|
||||
/// additive). Neither the path nor its dictionaries are created — an
|
||||
/// update must never create anything silently. Identity (handle, owning
|
||||
/// dictionary, name) stays; the entries and their DWG handle-reference
|
||||
/// stream are replaced and the record's verbatim raw payloads are dropped,
|
||||
/// so later saves emit the new content. Every refusal happens before the
|
||||
/// undo step is recorded.
|
||||
pub fn update_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
entries: Vec<codec::objects::XRecordEntry>,
|
||||
) -> Result<Handle, String> {
|
||||
use codec::objects::ObjectType;
|
||||
validated_xrecord_entries(&entries)?;
|
||||
let (_, _, handle) = self.xrecord_lookup(dictionary_path, name)?;
|
||||
self.push_undo("Update XRecord");
|
||||
let record = match self.document_mut().objects.get_mut(&handle) {
|
||||
Some(ObjectType::XRecord(record)) => record,
|
||||
_ => unreachable!("validated XRecord"),
|
||||
};
|
||||
record.entries = entries;
|
||||
// Rebuild the DWG object-reference stream from the new 330-369 entries
|
||||
// and drop the verbatim bytes an earlier load kept: the update fully
|
||||
// defines the record now.
|
||||
record.synchronize_object_references();
|
||||
record.preserve_object_reference_stream = false;
|
||||
record.entries_complete = true;
|
||||
record.raw_data.clear();
|
||||
record.raw_dwg_data = None;
|
||||
record.raw_dwg_handle_bits = 0;
|
||||
record.raw_dwg_version = None;
|
||||
self.set_dirty();
|
||||
self.publish_document_view();
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Delete the XRecord named `name` and its dictionary entry (API v8,
|
||||
/// additive). Returns the removed record with its prior content, so audits
|
||||
/// keep what they removed. Every refusal happens before the undo step is
|
||||
/// recorded.
|
||||
pub fn delete_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
) -> Result<codec::objects::XRecord, String> {
|
||||
use codec::objects::ObjectType;
|
||||
let (dictionary, name, handle) = self.xrecord_lookup(dictionary_path, name)?;
|
||||
self.push_undo("Delete XRecord");
|
||||
let removed = match self.document_mut().objects.remove(&handle) {
|
||||
Some(ObjectType::XRecord(record)) => record,
|
||||
_ => unreachable!("validated XRecord"),
|
||||
};
|
||||
if let Some(ObjectType::Dictionary(entries)) =
|
||||
self.document_mut().objects.get_mut(&dictionary)
|
||||
{
|
||||
entries
|
||||
.entries
|
||||
.retain(|(key, value)| !key.eq_ignore_ascii_case(&name) || *value != handle);
|
||||
}
|
||||
self.set_dirty();
|
||||
self.publish_document_view();
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// List the XRecord names under the dictionary addressed by
|
||||
/// `dictionary_path` (API v8, additive): the dictionary keys that address
|
||||
/// XRecord objects, in the dictionary's own order, so consumers can audit
|
||||
/// and export without knowing names in advance. Non-XRecord entries are
|
||||
/// not listed.
|
||||
pub fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
|
||||
use codec::objects::ObjectType;
|
||||
let dictionary = self.xrecord_dictionary_at(dictionary_path)?;
|
||||
let Some(ObjectType::Dictionary(entries)) = self.document().objects.get(&dictionary) else {
|
||||
return Err(format!("dictionary {dictionary:?} does not exist"));
|
||||
};
|
||||
Ok(entries
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|(key, handle)| match self.document().objects.get(handle) {
|
||||
Some(ObjectType::XRecord(_)) => Some(key.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Resolve an existing XRecord to its containing dictionary, its validated
|
||||
/// name and its handle, after validating the path, the name and the target
|
||||
/// kind.
|
||||
fn xrecord_lookup(
|
||||
&self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
) -> Result<(Handle, String, Handle), String> {
|
||||
use codec::objects::ObjectType;
|
||||
let dictionary = self.xrecord_dictionary_at(dictionary_path)?;
|
||||
let name = validated_symbol_name(name, "an XRecord name")?;
|
||||
let Some(ObjectType::Dictionary(entries)) = self.document().objects.get(&dictionary) else {
|
||||
return Err(format!("dictionary {dictionary:?} does not exist"));
|
||||
};
|
||||
let Some(handle) = entries.get(&name) else {
|
||||
return Err(format!("XRecord {name:?} does not exist"));
|
||||
};
|
||||
if !matches!(
|
||||
self.document().objects.get(&handle),
|
||||
Some(ObjectType::XRecord(_))
|
||||
) {
|
||||
return Err(format!("dictionary entry {name:?} is not an XRecord"));
|
||||
}
|
||||
Ok((dictionary, name, handle))
|
||||
}
|
||||
}
|
||||
|
||||
/// True when block `outer` (transitively) contains an insert of `target`.
|
||||
@@ -2824,6 +3142,28 @@ fn validated_symbol_name(raw: &str, what: &str) -> Result<String, String> {
|
||||
Ok(name.to_owned())
|
||||
}
|
||||
|
||||
/// Validate the entries an XRecord create or update wants to store: each needs
|
||||
/// a group code the codec model admits (1..=369; 5 and 105 are reserved for
|
||||
/// object handles). Refused before any change so bad input never reaches a
|
||||
/// file.
|
||||
fn validated_xrecord_entries(entries: &[codec::objects::XRecordEntry]) -> Result<(), String> {
|
||||
for entry in entries {
|
||||
if !(1..=369).contains(&entry.code) {
|
||||
return Err(format!(
|
||||
"XRecord group code {} is out of range 1..=369",
|
||||
entry.code
|
||||
));
|
||||
}
|
||||
if entry.code == 5 || entry.code == 105 {
|
||||
return Err(format!(
|
||||
"XRecord group code {} is reserved for the object handle",
|
||||
entry.code
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_text_style_config(
|
||||
style: &mut codec::tables::TextStyle,
|
||||
config: &ocs_plugin_api::host::TextStyleConfig,
|
||||
@@ -3100,6 +3440,39 @@ impl HostApi for HostSession<'_> {
|
||||
) -> Result<ocs_plugin_api::host::CommandOutcome, String> {
|
||||
self.run_command(request)
|
||||
}
|
||||
fn create_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
entries: Vec<ocs_plugin_api::host::XRecordEntry>,
|
||||
) -> Result<Handle, String> {
|
||||
self.create_xrecord(dictionary_path, name, entries)
|
||||
}
|
||||
fn read_xrecord(
|
||||
&self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
) -> Result<Vec<ocs_plugin_api::host::XRecordEntry>, String> {
|
||||
self.read_xrecord(dictionary_path, name)
|
||||
}
|
||||
fn update_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
entries: Vec<ocs_plugin_api::host::XRecordEntry>,
|
||||
) -> Result<Handle, String> {
|
||||
self.update_xrecord(dictionary_path, name, entries)
|
||||
}
|
||||
fn delete_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
) -> Result<ocs_plugin_api::host::XRecord, String> {
|
||||
self.delete_xrecord(dictionary_path, name)
|
||||
}
|
||||
fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
|
||||
self.list_xrecords(dictionary_path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand)
|
||||
@@ -3600,6 +3973,308 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xrecord_crud_round_trips_refusals_and_list() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let entries = || {
|
||||
vec![
|
||||
codec::objects::XRecordEntry::string(1, "{\"k\":\"road\",\"s\":1}"),
|
||||
codec::objects::XRecordEntry::double(40, 0.125),
|
||||
codec::objects::XRecordEntry::int16(70, 3),
|
||||
codec::objects::XRecordEntry::int32(90, -45000),
|
||||
codec::objects::XRecordEntry::bool(290, true),
|
||||
]
|
||||
};
|
||||
|
||||
// Create seeds the namespace: the dictionary chain is auto-created and
|
||||
// reading returns the entries verbatim.
|
||||
let alpha = host.create_xrecord(&["CVIFC"], "alpha", entries()).unwrap();
|
||||
assert!(!alpha.is_null());
|
||||
assert_eq!(host.read_xrecord(&["CVIFC"], "alpha").unwrap(), entries());
|
||||
// Dictionary lookup is case-insensitive, both for the path …
|
||||
assert_eq!(host.read_xrecord(&["cvifc"], "alpha").unwrap(), entries());
|
||||
// … and re-creating an existing name is refused without any change.
|
||||
assert!(host
|
||||
.create_xrecord(&["cvifc"], "ALPHA", entries())
|
||||
.unwrap_err()
|
||||
.contains("already exists"));
|
||||
|
||||
// Namespace reuse is idempotent: the root still holds one CVIFC entry.
|
||||
let beta = host.create_xrecord(&["CVIFC"], "beta", entries()).unwrap();
|
||||
let root = host.document().header.named_objects_dict_handle;
|
||||
let cvifc_entries = match host.document().objects.get(&root) {
|
||||
Some(codec::objects::ObjectType::Dictionary(dictionary)) => dictionary
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(key, _)| key.eq_ignore_ascii_case("CVIFC"))
|
||||
.count(),
|
||||
_ => 0,
|
||||
};
|
||||
assert_eq!(cvifc_entries, 1);
|
||||
|
||||
// Listing names only the keys that address XRecords, in dictionary
|
||||
// order; a nested dictionary is not an XRecord.
|
||||
host.create_xrecord(&["CVIFC", "LAYOUTS"], "nested", entries())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
host.list_xrecords(&["CVIFC"]).unwrap(),
|
||||
vec!["alpha".to_owned(), "beta".to_owned()]
|
||||
);
|
||||
assert_eq!(
|
||||
host.list_xrecords(&["CVIFC", "LAYOUTS"]).unwrap(),
|
||||
vec!["nested".to_owned()]
|
||||
);
|
||||
// The root dictionary holds non-XRecord entries (ACAD_LAYOUT et al.);
|
||||
// listing only reports XRecords.
|
||||
let root_names = host.list_xrecords(&[]).unwrap();
|
||||
assert!(!root_names
|
||||
.iter()
|
||||
.any(|n| n.eq_ignore_ascii_case("ACAD_LAYOUT")));
|
||||
|
||||
// Update refuses on a missing name and never creates anything.
|
||||
assert!(host
|
||||
.update_xrecord(&["CVIFC"], "gamma", entries())
|
||||
.unwrap_err()
|
||||
.contains("does not exist"));
|
||||
// Case-insensitive resolution finds the stored ALPHA and replaces its
|
||||
// entries atomically; the handle is stable.
|
||||
assert_eq!(
|
||||
host.update_xrecord(
|
||||
&["CVIFC"],
|
||||
"ALPHA",
|
||||
vec![codec::objects::XRecordEntry::string(1, "v2")]
|
||||
)
|
||||
.unwrap(),
|
||||
alpha
|
||||
);
|
||||
assert_eq!(
|
||||
host.read_xrecord(&["CVIFC"], "alpha").unwrap(),
|
||||
vec![codec::objects::XRecordEntry::string(1, "v2")]
|
||||
);
|
||||
|
||||
// Entries are validated before any change: reserved and out-of-range
|
||||
// group codes refuse (5 and 105 are the object handle codes).
|
||||
assert!(host
|
||||
.create_xrecord(
|
||||
&["CVIFC"],
|
||||
"gamma",
|
||||
vec![codec::objects::XRecordEntry::int16(5, 1)]
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("reserved"));
|
||||
assert!(host
|
||||
.create_xrecord(
|
||||
&["CVIFC"],
|
||||
"gamma",
|
||||
vec![codec::objects::XRecordEntry::int16(400, 1)]
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("out of range"));
|
||||
assert!(host
|
||||
.update_xrecord(
|
||||
&["CVIFC"],
|
||||
"alpha",
|
||||
vec![codec::objects::XRecordEntry::int16(105, 1)]
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
host.read_xrecord(&["CVIFC"], "alpha").unwrap(),
|
||||
vec![codec::objects::XRecordEntry::string(1, "v2")]
|
||||
);
|
||||
// Dictionary and record names are symbol names.
|
||||
assert!(host.create_xrecord(&["CVIFC"], " ", entries()).is_err());
|
||||
assert!(host
|
||||
.create_xrecord(&["CVIFC", "A|B"], "gamma", entries())
|
||||
.is_err());
|
||||
assert!(host.create_xrecord(&["CVIFC"], "a<b", entries()).is_err());
|
||||
|
||||
// Reads distinguish a missing dictionary, a missing name and a name
|
||||
// that addresses another object kind.
|
||||
assert!(host
|
||||
.read_xrecord(&["NOPE"], "alpha")
|
||||
.unwrap_err()
|
||||
.contains("does not exist"));
|
||||
assert!(host
|
||||
.read_xrecord(&["CVIFC", "LAYOUTS"], "alpha")
|
||||
.unwrap_err()
|
||||
.contains("does not exist"));
|
||||
assert!(host
|
||||
.delete_xrecord(&["CVIFC"], "LAYOUTS")
|
||||
.unwrap_err()
|
||||
.contains("not an XRecord"));
|
||||
|
||||
// Delete removes exactly the named record and returns its content.
|
||||
let removed = host.delete_xrecord(&["CVIFC"], "beta").unwrap();
|
||||
assert_eq!(removed.handle, beta);
|
||||
assert_eq!(removed.entries, entries());
|
||||
assert_eq!(
|
||||
host.list_xrecords(&["CVIFC"]).unwrap(),
|
||||
vec!["alpha".to_owned()]
|
||||
);
|
||||
assert!(host
|
||||
.read_xrecord(&["CVIFC"], "beta")
|
||||
.unwrap_err()
|
||||
.contains("does not exist"));
|
||||
assert!(host.delete_xrecord(&["CVIFC"], "beta").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xrecord_mutations_are_single_undo_steps_that_restore_objects() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let original_entries = || vec![codec::objects::XRecordEntry::string(1, "one")];
|
||||
let changed_entries = || {
|
||||
vec![
|
||||
codec::objects::XRecordEntry::string(1, "changed"),
|
||||
codec::objects::XRecordEntry::int16(70, 9),
|
||||
]
|
||||
};
|
||||
let (alpha, beta);
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
alpha = host
|
||||
.create_xrecord(&["CVIFC"], "alpha", original_entries())
|
||||
.unwrap();
|
||||
beta = host
|
||||
.create_xrecord(&["CVIFC"], "beta", original_entries())
|
||||
.unwrap();
|
||||
// Refusals record no undo step.
|
||||
assert!(host
|
||||
.create_xrecord(&["CVIFC"], "alpha", original_entries())
|
||||
.is_err());
|
||||
assert!(host
|
||||
.update_xrecord(&["CVIFC"], "gamma", original_entries())
|
||||
.is_err());
|
||||
host.update_xrecord(&["CVIFC"], "alpha", changed_entries())
|
||||
.unwrap();
|
||||
let removed = host.delete_xrecord(&["CVIFC"], "beta").unwrap();
|
||||
assert_eq!(removed.handle, beta);
|
||||
}
|
||||
app.finish_pending_history(0);
|
||||
// Four mutating calls = four undo steps; the refusals recorded none.
|
||||
assert_eq!(app.tabs[0].history.undo_stack.len(), 4);
|
||||
|
||||
let xrecord = |doc: &CadDocument, handle: Handle| -> Option<codec::objects::XRecord> {
|
||||
match doc.objects.get(&handle) {
|
||||
Some(codec::objects::ObjectType::XRecord(record)) => Some(record.clone()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let has_dictionary_entry = |doc: &CadDocument, name: &str| -> bool {
|
||||
let root = doc.header.named_objects_dict_handle;
|
||||
match doc.objects.get(&root) {
|
||||
Some(codec::objects::ObjectType::Dictionary(dictionary)) => dictionary
|
||||
.entries
|
||||
.iter()
|
||||
.any(|(key, _)| key.eq_ignore_ascii_case(name)),
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
|
||||
// Undo the delete: beta is back with its exact prior entries.
|
||||
app.undo_steps(1);
|
||||
let document = &app.tabs[0].scene.document;
|
||||
assert_eq!(xrecord(document, beta).unwrap().entries, original_entries());
|
||||
// Undo the update: alpha's entries are restored byte-exactly.
|
||||
app.undo_steps(1);
|
||||
let document = &app.tabs[0].scene.document;
|
||||
assert_eq!(
|
||||
xrecord(document, alpha).unwrap().entries,
|
||||
original_entries()
|
||||
);
|
||||
// Undo beta's creation.
|
||||
app.undo_steps(1);
|
||||
let document = &app.tabs[0].scene.document;
|
||||
assert!(xrecord(document, beta).is_none());
|
||||
assert!(xrecord(document, alpha).is_some());
|
||||
// Undo alpha's creation: the auto-created dictionary went with it,
|
||||
// because the pending history snapshot restores the whole objects map.
|
||||
app.undo_steps(1);
|
||||
let document = &app.tabs[0].scene.document;
|
||||
assert!(xrecord(document, alpha).is_none());
|
||||
assert!(
|
||||
!has_dictionary_entry(document, "CVIFC"),
|
||||
"undoing the first create removed the auto-created dictionary"
|
||||
);
|
||||
// Redo re-applies the whole ladder.
|
||||
app.redo_steps(4);
|
||||
let document = &app.tabs[0].scene.document;
|
||||
assert_eq!(xrecord(document, alpha).unwrap().entries, changed_entries());
|
||||
assert!(xrecord(document, beta).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xrecords_survive_dxf_and_dwg_round_trips() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let entries = || {
|
||||
vec![
|
||||
codec::objects::XRecordEntry::string(1, "{\"k\":\"road\",\"s\":1}"),
|
||||
codec::objects::XRecordEntry::double(40, 0.125),
|
||||
codec::objects::XRecordEntry::int16(70, 3),
|
||||
codec::objects::XRecordEntry::int32(90, -45000),
|
||||
codec::objects::XRecordEntry::bool(290, true),
|
||||
]
|
||||
};
|
||||
// Create carries a first payload, then the update replaces it: if the
|
||||
// writers preferred stale raw bytes over the typed entries, the loaded
|
||||
// record would still read "stale".
|
||||
host.create_xrecord(
|
||||
&["CVIFC", "RECORDS"],
|
||||
"alpha",
|
||||
vec![codec::objects::XRecordEntry::string(1, "stale")],
|
||||
)
|
||||
.unwrap();
|
||||
host.update_xrecord(&["CVIFC", "RECORDS"], "alpha", entries())
|
||||
.unwrap();
|
||||
|
||||
let find_xrecord =
|
||||
|doc: &CadDocument, path: &[&str], name: &str| -> Option<codec::objects::XRecord> {
|
||||
let mut current = doc.header.named_objects_dict_handle;
|
||||
for fragment in path {
|
||||
match doc.objects.get(¤t) {
|
||||
Some(codec::objects::ObjectType::Dictionary(dictionary)) => {
|
||||
current = dictionary.get(fragment)?
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
let Some(codec::objects::ObjectType::Dictionary(dictionary)) =
|
||||
doc.objects.get(¤t)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let handle = dictionary.get(name)?;
|
||||
match doc.objects.get(&handle) {
|
||||
Some(codec::objects::ObjectType::XRecord(record)) => Some(record.clone()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
let dwg = crate::io::load_bytes(
|
||||
"xrec.dwg",
|
||||
codec::DwgWriter::write_to_vec(host.document()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let reloaded = find_xrecord(&dwg, &["CVIFC", "RECORDS"], "alpha")
|
||||
.expect("XRecord in the reloaded DWG");
|
||||
assert_eq!(reloaded.entries, entries());
|
||||
|
||||
let dxf = crate::io::load_bytes(
|
||||
"xrec.dxf",
|
||||
codec::DxfWriter::new(host.document())
|
||||
.write_to_vec()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let reloaded = find_xrecord(&dxf, &["CVIFC", "RECORDS"], "alpha")
|
||||
.expect("XRecord in the reloaded DXF");
|
||||
assert_eq!(reloaded.entries, entries());
|
||||
}
|
||||
|
||||
/// Opt-in end-to-end check using the staged Python cdylib and the actual
|
||||
/// OCS executable as its out-of-process runner. Run with OCS_TEST_PYTHON_PLUGIN
|
||||
/// and OCS_PLUGIN_RUNNER_EXE set; unlike the local-socket protocol test,
|
||||
|
||||
Reference in new issue
Block a user