use acadrust::tables::{Table, TableEntry}; use iced::Task; use serde::{Serialize, de::DeserializeOwned}; use serde_json::{Map, Value, json}; use std::collections::VecDeque; use std::sync::OnceLock; use super::{Message, OpenCADStudio}; const COLLECTIONS: &[(&str, bool)] = &[ ("entities", true), ("objects", true), ("layers", true), ("line_types", true), ("text_styles", true), ("block_records", true), ("dim_styles", true), ("app_ids", true), ("views", true), ("vports", true), ("ucss", true), ("vx_table", true), ("header", true), ("summary_info", true), ("classes", false), ("block_visibility", false), ("context_scales", false), ("block_representations", false), ("fields", false), ("dgn_line_style_definitions", false), ("dgn_line_style_components", false), ("vx_control_entries", false), ("section_view_style", false), ("view_rep_references", false), ("section_view_representations", false), ("notifications", false), ("preview", false), ("document", false), ]; fn collection_root_type(collection: &str) -> Option<&'static str> { Some(match collection { "entities" => "EntityType", "objects" => "ObjectType", "layers" => "Layer", "line_types" => "LineType", "text_styles" => "TextStyle", "block_records" => "BlockRecord", "dim_styles" => "DimStyle", "app_ids" => "AppId", "views" => "View", "vports" => "VPort", "ucss" => "Ucs", "vx_table" => "VxTableRecord", "header" => "HeaderVariables", "summary_info" => "SummaryInfo", "classes" => "DxfClass", "block_visibility" => "BlockVisibilityParameter", "context_scales" | "block_representations" | "vx_control_entries" | "section_view_representations" | "view_rep_references" => "Handle", "fields" => "FieldDef", "dgn_line_style_definitions" => "DgnLsDefinition", "dgn_line_style_components" => "DgnLsComponent", "notifications" => "NotificationCollection", "preview" => "Preview", "section_view_style" => "EntitySectionViewStyle", // This aggregate is assembled by OCS and has a live inferred schema. "document" => "Value", _ => return None, }) } fn collection_is_mutable(collection: &str) -> bool { COLLECTIONS .iter() .find(|(name, _)| *name == collection) .is_some_and(|(_, mutable)| *mutable) } fn collection_record_name(collection: &str) -> Option<&'static str> { Some(match collection { "classes" => "Class", "fields" => "Field", "dgn_line_style_definitions" => "LineStyleDefinition", "dgn_line_style_components" => "LineStyleComponent", "section_view_style" => "SectionViewStyle", "view_rep_references" => "HandleReferences", "notifications" => "Notifications", "document" => "Document", _ => collection_root_type(collection)?, }) } fn identity_paths(collection: &str) -> &'static [&'static str] { match collection { "entities" => &["/common/handle", "/common/owner_handle"], "objects" => &[ "/handle", "/owner", "/common/handle", "/common/owner_handle", ], "header" => &["/handle_seed"], "summary_info" => &[], "layers" | "line_types" | "text_styles" | "block_records" | "dim_styles" | "app_ids" | "views" | "vports" | "ucss" | "vx_table" => &["/handle", "/name"], _ => &[], } } fn path_changes_identity(collection: &str, path: &str) -> bool { path_is_identity_field(collection, path) || identity_paths(collection).iter().any(|identity| { path == *identity || identity .strip_prefix(path) .is_some_and(|suffix| suffix.starts_with('/')) }) } fn path_is_identity_field(collection: &str, path: &str) -> bool { match collection { "entities" => path.ends_with("/common/handle") || path.ends_with("/common/owner_handle"), "objects" => { matches!(path, "/handle" | "/owner") || path.ends_with("/common/handle") || path.ends_with("/common/owner_handle") } _ => identity_paths(collection).contains(&path), } } fn collect_identity_values(value: &Value, collection: &str, path: &str, output: &mut Vec) { if path_is_identity_field(collection, path) { output.push(json!({"path":path,"value":value})); } match value { Value::Object(object) => { for (name, value) in object { collect_identity_values( value, collection, &format!("{path}/{}", json_pointer_name(name)), output, ); } } Value::Array(values) => { for (index, value) in values.iter().enumerate() { collect_identity_values(value, collection, &format!("{path}/{index}"), output); } } _ => {} } } fn registry_types() -> &'static Map { static REGISTRY: OnceLock = OnceLock::new(); REGISTRY.get_or_init(|| { serde_json::from_str(ocs_plugin_api::get_embedded_type_registry_json()) .expect("embedded type registry") })["types"] .as_object() .expect("type registry object") } fn failure(code: &str, message: impl Into) -> Value { json!({"ok":false,"status":"failed","code":code,"error":message.into()}) } fn json_value(value: &impl Serialize) -> Result { serde_json::to_value(value).map_err(|error| error.to_string()) } fn enum_parts(value: &impl Serialize) -> Result<(String, Value), String> { let Value::Object(wrapper) = json_value(value)? else { return Err("record type is not represented by an object".into()); }; if wrapper.len() != 1 { return Err("record type has an invalid representation".into()); } Ok(wrapper.into_iter().next().expect("one entry")) } fn decode_enum(kind: &str, properties: Value) -> Result { let mut wrapper = Map::new(); wrapper.insert(kind.to_string(), properties); serde_json::from_value(Value::Object(wrapper)).map_err(|error| error.to_string()) } fn handle_text(handle: acadrust::Handle) -> String { format!("{:X}", handle.value()) } fn without_hex_prefix(value: &str) -> &str { value .strip_prefix("0x") .or_else(|| value.strip_prefix("0X")) .unwrap_or(value) } fn record( collection: &str, kind: &str, handle: Option, name: Option<&str>, mutable: bool, properties: Value, ) -> Value { let mut value = json!({ "collection":collection, "type":kind, "mutable":mutable, "properties":properties, }); let object = value.as_object_mut().expect("record object"); if let Some(handle) = handle { object.insert("handle".into(), json!(handle_text(handle))); } if let Some(name) = name { object.insert("name".into(), json!(name)); } value } fn entity_record(entity: &acadrust::EntityType) -> Result { let (kind, properties) = enum_parts(entity)?; let mut value = record( "entities", &kind, Some(entity.common().handle), None, true, properties, ); let object = value.as_object_mut().expect("record object"); object.insert( "display_type".into(), json!(crate::entities::names::ui_name(entity)), ); object.insert( "record_type".into(), json!(crate::entities::names::dxf_name(entity)), ); Ok(value) } fn object_record( handle: acadrust::Handle, object: &acadrust::objects::ObjectType, ) -> Result { let (kind, properties) = enum_parts(object)?; Ok(record( "objects", &kind, Some(handle), None, true, properties, )) } fn table_records( collection: &str, kind: &str, table: &Table, ) -> Result, String> { table .iter() .map(|entry| { Ok(record( collection, kind, Some(entry.handle()), Some(entry.name()), true, json_value(entry)?, )) }) .collect() } fn map_records( collection: &str, kind: &str, values: &std::collections::HashMap, ) -> Result, String> { let mut entries: Vec<_> = values.iter().collect(); entries.sort_by_key(|(handle, _)| handle.value()); entries .into_iter() .map(|(handle, value)| { Ok(record( collection, kind, Some(*handle), None, false, json_value(value)?, )) }) .collect() } fn collection_records( document: &acadrust::CadDocument, collection: &str, ) -> Result, String> { match collection { "entities" => document.entities().map(entity_record).collect(), "objects" => { let mut objects: Vec<_> = document.objects.iter().collect(); objects.sort_by_key(|(handle, _)| handle.value()); objects .into_iter() .map(|(handle, object)| object_record(*handle, object)) .collect() } "layers" => table_records("layers", "Layer", &document.layers), "line_types" => table_records("line_types", "LineType", &document.line_types), "text_styles" => table_records("text_styles", "TextStyle", &document.text_styles), "block_records" => table_records("block_records", "BlockRecord", &document.block_records), "dim_styles" => table_records("dim_styles", "DimStyle", &document.dim_styles), "app_ids" => table_records("app_ids", "AppId", &document.app_ids), "views" => table_records("views", "View", &document.views), "vports" => table_records("vports", "VPort", &document.vports), "ucss" => table_records("ucss", "Ucs", &document.ucss), "vx_table" => table_records("vx_table", "VxTableRecord", &document.vx_table), "header" => Ok(vec![record( "header", "HeaderVariables", None, Some("header"), true, json_value(&document.header)?, )]), "summary_info" => Ok(vec![record( "summary_info", "SummaryInfo", None, Some("summary_info"), true, json_value(&document.summary_info)?, )]), "classes" => document .classes .iter() .map(|value| { Ok(record( "classes", "Class", None, Some(&value.dxf_name), false, json_value(value)?, )) }) .collect(), "block_visibility" => map_records( "block_visibility", "BlockVisibilityParameter", &document.block_visibility_params, ), "context_scales" => map_records("context_scales", "Handle", &document.context_scales), "block_representations" => map_records( "block_representations", "Handle", &document.block_representations, ), "fields" => map_records("fields", "Field", &document.fields), "dgn_line_style_definitions" => map_records( "dgn_line_style_definitions", "LineStyleDefinition", &document.dgn_ls_definitions, ), "dgn_line_style_components" => map_records( "dgn_line_style_components", "LineStyleComponent", &document.dgn_ls_components, ), "vx_control_entries" => Ok(document .vx_control_entries .iter() .map(|handle| { record( "vx_control_entries", "Handle", Some(*handle), None, false, json!({}), ) }) .collect()), "section_view_style" => Ok(document .section_view_style .as_ref() .map(|value| { record( "section_view_style", "SectionViewStyle", None, Some("section_view_style"), false, json_value(value).unwrap_or(Value::Null), ) }) .into_iter() .collect()), "view_rep_references" => map_records( "view_rep_references", "HandleReferences", &document.view_rep_refs, ), "section_view_representations" => Ok(document .section_view_reps .iter() .map(|handle| { record( "section_view_representations", "Handle", Some(*handle), None, false, json!({}), ) }) .collect()), "notifications" => Ok(vec![record( "notifications", "Notifications", None, Some("notifications"), false, json_value(&document.notifications)?, )]), "preview" => Ok(document .preview .as_ref() .map(|value| { record( "preview", "Preview", None, Some("preview"), false, json_value(value).unwrap_or(Value::Null), ) }) .into_iter() .collect()), "document" => Ok(vec![record( "document", "Document", None, Some("document"), false, json!({ "version":document.version, "maintenance_version":document.maintenance_version, "source_path":document.source_path, "source_version":document.dwg_source_version, }), )]), _ => Err(format!("unknown record collection: {collection}")), } } fn collection_count(document: &acadrust::CadDocument, collection: &str) -> usize { match collection { "entities" => document.entities().count(), "objects" => document.objects.len(), "layers" => document.layers.len(), "line_types" => document.line_types.len(), "text_styles" => document.text_styles.len(), "block_records" => document.block_records.len(), "dim_styles" => document.dim_styles.len(), "app_ids" => document.app_ids.len(), "views" => document.views.len(), "vports" => document.vports.len(), "ucss" => document.ucss.len(), "vx_table" => document.vx_table.len(), "classes" => document.classes.len(), "block_visibility" => document.block_visibility_params.len(), "context_scales" => document.context_scales.len(), "block_representations" => document.block_representations.len(), "fields" => document.fields.len(), "dgn_line_style_definitions" => document.dgn_ls_definitions.len(), "dgn_line_style_components" => document.dgn_ls_components.len(), "vx_control_entries" => document.vx_control_entries.len(), "section_view_style" => usize::from(document.section_view_style.is_some()), "view_rep_references" => document.view_rep_refs.len(), "section_view_representations" => document.section_view_reps.len(), "preview" => usize::from(document.preview.is_some()), _ => 1, } } fn compare( actual: Option<&Value>, operator: &str, expected: Option<&Value>, ) -> Result { let exists = actual.is_some(); if operator == "exists" { return Ok(exists); } if operator == "not_exists" { return Ok(!exists); } let Some(actual) = actual else { return Ok(false); }; let expected = expected.ok_or_else(|| format!("filter operator {operator} requires value"))?; Ok(match operator { "eq" => actual == expected, "ne" => actual != expected, "lt" | "lte" | "gt" | "gte" => { let left = actual .as_f64() .ok_or_else(|| format!("filter operator {operator} requires numeric values"))?; let right = expected .as_f64() .ok_or_else(|| format!("filter operator {operator} requires numeric values"))?; match operator { "lt" => left < right, "lte" => left <= right, "gt" => left > right, _ => left >= right, } } "contains" => match (actual, expected) { (Value::String(left), Value::String(right)) => left.contains(right), (Value::Array(values), expected) => values.contains(expected), _ => return Err("contains requires a string/string or array/value pair".into()), }, "starts_with" => actual .as_str() .zip(expected.as_str()) .is_some_and(|(left, right)| left.starts_with(right)), "ends_with" => actual .as_str() .zip(expected.as_str()) .is_some_and(|(left, right)| left.ends_with(right)), "in" => expected .as_array() .is_some_and(|values| values.contains(actual)), _ => return Err(format!("unknown filter operator: {operator}")), }) } fn matches_filters(record: &Value, request: &Value) -> Result { if request["handle"].as_str().is_some_and(|requested| { !record["handle"] .as_str() .is_some_and(|actual| actual.eq_ignore_ascii_case(without_hex_prefix(requested))) }) { return Ok(false); } if let Some(handles) = request["handles"].as_array() { let Some(actual) = record["handle"].as_str() else { return Ok(false); }; if !handles .iter() .filter_map(Value::as_str) .any(|requested| actual.eq_ignore_ascii_case(without_hex_prefix(requested))) { return Ok(false); } } if request["type"].as_str().is_some_and(|requested| { !["type", "display_type", "record_type"].iter().any(|key| { record[*key] .as_str() .is_some_and(|actual| actual.eq_ignore_ascii_case(requested)) }) }) { return Ok(false); } if request["name"].as_str().is_some_and(|requested| { !record["name"] .as_str() .is_some_and(|actual| actual.eq_ignore_ascii_case(requested)) }) { return Ok(false); } if let Some(filters) = request["where"].as_array() { for filter in filters { let path = filter["path"] .as_str() .ok_or_else(|| "each filter requires a JSON Pointer path".to_string())?; if !path.is_empty() && !path.starts_with('/') { return Err(format!("invalid JSON Pointer: {path}")); } let actual = if path.is_empty() { Some(&record["properties"]) } else { record["properties"].pointer(path) }; let operator = filter["op"].as_str().unwrap_or("eq"); if !compare(actual, operator, filter.get("value"))? { return Ok(false); } } } Ok(true) } fn project_paths(mut record: Value, paths: Option<&Vec>) -> Result { let Some(paths) = paths else { return Ok(record); }; let mut projected = Map::new(); for path in paths { let path = path .as_str() .ok_or_else(|| "paths must contain JSON Pointer strings".to_string())?; if !path.is_empty() && !path.starts_with('/') { return Err(format!("invalid JSON Pointer: {path}")); } let value = if path.is_empty() { Some(&record["properties"]) } else { record["properties"].pointer(path) }; projected.insert(path.to_string(), value.cloned().unwrap_or(Value::Null)); } let object = record.as_object_mut().expect("record object"); object.remove("properties"); object.insert("values".into(), Value::Object(projected)); Ok(record) } fn resolved_type_name(requested: &str) -> Option { let types = registry_types(); types .get_key_value(requested) .or_else(|| { types .iter() .find(|(name, _)| name.eq_ignore_ascii_case(requested)) }) .map(|(name, _)| name.clone()) } fn collect_type_references(value: &Value, pending: &mut VecDeque) { match value { Value::Object(object) => { for (key, value) in object { if key == "type_id" { if let Some(type_id) = value.as_str() { for token in type_id.split(|character: char| { !character.is_ascii_alphanumeric() && character != '_' }) { if registry_types().contains_key(token) { pending.push_back(token.to_string()); } } } } collect_type_references(value, pending); } } Value::Array(values) => { for value in values { collect_type_references(value, pending); } } _ => {} } } fn referenced_definitions(root: &str) -> Map { let mut definitions = Map::new(); let mut pending = VecDeque::from([root.to_string()]); while let Some(name) = pending.pop_front() { if definitions.contains_key(&name) { continue; } let Some(info) = registry_types().get(&name) else { continue; }; collect_type_references(info, &mut pending); definitions.insert(name, info.clone()); } definitions } fn json_pointer_name(name: &str) -> String { name.replace('~', "~0").replace('/', "~1") } fn field_unit(name: &str, type_id: &str) -> Option<&'static str> { let numeric = matches!( type_id, "f32" | "f64" | "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "Vector2" | "Vector3" ); if !numeric { return None; } let name = name.to_ascii_lowercase(); if name.contains("angle") || name == "rotation" || name.contains("twist") { return Some("radians"); } if name.contains("scale") || name.contains("ratio") || name.contains("factor") { return Some("unitless"); } if [ "point", "position", "origin", "center", "location", "elevation", "height", "width", "length", "radius", "diameter", "offset", "thickness", "distance", "size", "spacing", ] .iter() .any(|part| name.contains(part)) { return Some("drawing_units"); } None } fn type_constraints(type_id: &str) -> Option { let integer_bounds = match type_id { "i8" => Some(json!({"minimum":i8::MIN,"maximum":i8::MAX})), "i16" => Some(json!({"minimum":i16::MIN,"maximum":i16::MAX})), "i32" => Some(json!({"minimum":i32::MIN,"maximum":i32::MAX})), "i64" => Some(json!({"minimum":i64::MIN,"maximum":i64::MAX})), "u8" => Some(json!({"minimum":0,"maximum":u8::MAX})), "u16" => Some(json!({"minimum":0,"maximum":u16::MAX})), "u32" => Some(json!({"minimum":0,"maximum":u32::MAX})), "u64" | "Handle" => Some(json!({"minimum":0,"maximum":u64::MAX})), _ => None, }; if integer_bounds.is_some() { return integer_bounds; } let info = registry_types().get(type_id)?; if info["kind"] != "Enum" { return None; } Some(json!({ "allowed_variants":info["variants"].as_array().into_iter().flatten() .filter_map(|variant|variant["name"].as_str()).collect::>() })) } fn append_schema_fields( owner_type: &str, type_fields: &[Value], prefix: &str, collection: Option<&str>, depth: usize, stack: &mut Vec, fields: &mut Vec, ) { for field in type_fields { let Some(name) = field["name"].as_str() else { continue; }; let type_id = field["type_id"].as_str().unwrap_or("Value"); let path = format!("{prefix}/{}", json_pointer_name(name)); let sequence = field["is_sequence"].as_bool().unwrap_or(false); let writable = collection.is_some_and(collection_is_mutable) && collection.is_none_or(|collection| !path_changes_identity(collection, &path)); let mut description = json!({ "path":path, "name":name, "description":format!("{} property on {owner_type}", name.replace('_', " ")), "type":type_id, "optional":field["optional"].as_bool().unwrap_or(false), "sequence":sequence, "writable":writable, }); let object = description.as_object_mut().expect("field description"); if sequence { object.insert( "item_path_template".into(), json!(format!("{path}/{{index}}")), ); } if let Some(unit) = field_unit(name, type_id) { object.insert("unit".into(), json!(unit)); } if let Some(constraints) = type_constraints(type_id) { object.insert("constraints".into(), constraints); } fields.push(description); let nested_prefix = if sequence { format!("{path}/{{index}}") } else { path }; if registry_types().contains_key(type_id) { append_field_descriptions( type_id, &nested_prefix, collection, depth + 1, stack, fields, ); } } } fn append_field_descriptions( type_name: &str, prefix: &str, collection: Option<&str>, depth: usize, stack: &mut Vec, fields: &mut Vec, ) { if depth > 12 || stack.iter().any(|name| name == type_name) { return; } let Some(info) = registry_types().get(type_name) else { return; }; stack.push(type_name.to_string()); match info["kind"].as_str() { Some("Struct") => append_schema_fields( type_name, info["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]), prefix, collection, depth, stack, fields, ), Some("Enum") => append_enum_descriptions( type_name, info["variants"] .as_array() .map(Vec::as_slice) .unwrap_or(&[]), prefix, collection, depth, stack, fields, ), _ => {} } stack.pop(); } fn append_enum_descriptions( enum_type: &str, variants: &[Value], prefix: &str, collection: Option<&str>, depth: usize, stack: &mut Vec, fields: &mut Vec, ) { for variant in variants { let Some(variant_name) = variant["name"].as_str() else { continue; }; let Some(variant_fields) = variant["fields"] .as_array() .filter(|fields| !fields.is_empty()) else { continue; }; let path = format!("{prefix}/{}", json_pointer_name(variant_name)); let newtype = (variant_fields.len() == 1 && variant_fields[0]["name"] == "0") .then(|| &variant_fields[0]); let type_id = newtype .and_then(|field| field["type_id"].as_str()) .unwrap_or("struct_variant"); let sequence = newtype .and_then(|field| field["is_sequence"].as_bool()) .unwrap_or(false); let writable = collection.is_some_and(collection_is_mutable) && collection.is_none_or(|collection| !path_changes_identity(collection, &path)); let mut description = json!({ "path":path, "name":variant_name, "description":format!("{variant_name} variant on {enum_type}"), "type":type_id, "enum":enum_type, "variant":variant_name, "optional":false, "sequence":sequence, "writable":writable, }); if sequence { description .as_object_mut() .expect("variant description") .insert( "item_path_template".into(), json!(format!("{path}/{{index}}")), ); } if let Some(unit) = field_unit(variant_name, type_id) { description .as_object_mut() .expect("variant description") .insert("unit".into(), json!(unit)); } if let Some(constraints) = type_constraints(type_id) { description .as_object_mut() .expect("variant description") .insert("constraints".into(), constraints); } fields.push(description); if newtype.is_some() { if registry_types().contains_key(type_id) { let nested_prefix = if sequence { format!("{path}/{{index}}") } else { path }; append_field_descriptions( type_id, &nested_prefix, collection, depth + 1, stack, fields, ); } } else { append_schema_fields( variant_name, variant_fields, &path, collection, depth + 1, stack, fields, ); } } } fn append_variant_field_descriptions( enum_type: &str, variant_name: &str, collection: Option<&str>, fields: &mut Vec, ) { let Some(variant_fields) = registry_types() .get(enum_type) .and_then(|info| info["variants"].as_array()) .and_then(|variants| { variants.iter().find(|variant| { variant["name"] .as_str() .is_some_and(|name| name.eq_ignore_ascii_case(variant_name)) }) }) .and_then(|variant| variant["fields"].as_array()) else { return; }; let mut stack = vec![enum_type.to_string()]; append_schema_fields( variant_name, variant_fields, "", collection, 0, &mut stack, fields, ); } fn runtime_value_schema(value: &Value, collection: &str, path: &str) -> Value { let mut schema = match value { Value::Null => json!({"type":"null"}), Value::Bool(value) => json!({"type":"boolean","example":value}), Value::Number(value) if value.is_i64() || value.is_u64() => { json!({"type":"integer","example":value}) } Value::Number(value) => json!({"type":"number","example":value}), Value::String(value) => { let example: String = value.chars().take(160).collect(); json!({"type":"string","example":example}) } Value::Array(values) => { let item = values .first() .map(|value| runtime_value_schema(value, collection, &format!("{path}/0"))) .unwrap_or_else(|| json!({})); json!({"type":"array","items":item}) } Value::Object(values) => { let properties = values .iter() .map(|(name, value)| { let child_path = format!("{path}/{}", json_pointer_name(name)); ( name.clone(), runtime_value_schema(value, collection, &child_path), ) }) .collect::>(); json!({ "type":"object", "properties":properties, "required":values.keys().collect::>(), "additionalProperties":false, }) } }; if !path.is_empty() { let object = schema.as_object_mut().expect("runtime schema"); object.insert( "readOnly".into(), json!(!collection_is_mutable(collection) || path_changes_identity(collection, path)), ); let name = path.rsplit('/').next().unwrap_or(""); let scalar_type = if value.is_number() { "f64" } else { "Value" }; if let Some(unit) = field_unit(name, scalar_type) { object.insert("x-unit".into(), json!(unit)); } } schema } fn editing_description(collection: Option<&str>) -> Value { let mutable = collection.is_some_and(collection_is_mutable); json!({ "read_operation":"records", "write_operation":mutable.then_some("set_properties"), "selector":match collection { Some("entities" | "objects") => "handle", Some("header" | "summary_info") => "singleton", Some(_) if mutable => "handle or name", Some(_) => "read-only derived record", None => "collection plus handle or name", }, "path":"RFC 6901 JSON Pointer relative to record.properties", "sequence_path":"Replace {index} in item_path_template with a current zero-based array index", "identity_paths":collection.map(identity_paths).unwrap_or(&[]), "atomic":true, "compare_and_set":"Add expected to an update to reject stale field values", "validation":["document revision","request id","JSON type","enum variant","integer bounds","record identity","layer lock"], }) } fn collection_record_types(collection: &str) -> Vec { let root = collection_root_type(collection).unwrap_or("Value"); if matches!(collection, "entities" | "objects") { return registry_types()[root]["variants"] .as_array() .into_iter() .flatten() .map(|variant| { let fields = variant["fields"].as_array(); let schema_type = fields .filter(|fields| fields.len() == 1 && fields[0]["name"] == "0") .and_then(|fields| fields[0]["type_id"].as_str()) .unwrap_or(root); json!({"type":variant["name"],"schema_type":schema_type}) }) .collect(); } vec![json!({ "type":collection_record_name(collection).unwrap_or(root), "schema_type":root, })] } fn requested_handle(request: &Value) -> Result { request["handle"] .as_str() .and_then(|value| u64::from_str_radix(without_hex_prefix(value), 16).ok()) .map(acadrust::Handle::new) .ok_or_else(|| "set_properties requires a hexadecimal handle".to_string()) } fn apply_updates( properties: &mut Value, request: &Value, collection: &str, ) -> Result, String> { let updates = request["updates"] .as_array() .ok_or_else(|| "set_properties requires a non-empty updates array".to_string())?; if updates.is_empty() { return Err("set_properties requires a non-empty updates array".into()); } let mut original_identity = Vec::new(); collect_identity_values(properties, collection, "", &mut original_identity); let mut paths = Vec::with_capacity(updates.len()); for update in updates { let path = update["path"] .as_str() .ok_or_else(|| "each update requires a JSON Pointer path".to_string())?; if path.is_empty() || !path.starts_with('/') { return Err("updates require a non-empty JSON Pointer path".into()); } if path_changes_identity(collection, path) { return Err(format!("{path} is read-only identity data")); } let value = update .get("value") .ok_or_else(|| format!("update {path} requires value"))? .clone(); let target = properties .pointer_mut(path) .ok_or_else(|| format!("property does not exist: {path}"))?; if let Some(expected) = update.get("expected") { if target != expected { return Err(format!("property changed before update: {path}")); } } *target = value; paths.push(path.to_string()); } let mut edited_identity = Vec::new(); collect_identity_values(properties, collection, "", &mut edited_identity); if edited_identity != original_identity { return Err("record identity is read-only".into()); } Ok(paths) } fn patched_table_entry( table: &Table, request: &Value, collection: &str, ) -> Result<(T, T, Vec), String> where T: Clone + PartialEq + Serialize + DeserializeOwned + TableEntry, { let requested = request["handle"].as_str().and_then(|value| { u64::from_str_radix(without_hex_prefix(value), 16) .ok() .map(acadrust::Handle::new) }); let name = request["name"].as_str(); let source = table .iter() .find(|entry| { requested.is_some_and(|handle| entry.handle() == handle) || name.is_some_and(|name| entry.name().eq_ignore_ascii_case(name)) }) .cloned() .ok_or_else(|| format!("record does not exist in {collection}"))?; let mut properties = json_value(&source)?; let paths = apply_updates(&mut properties, request, collection)?; let edited: T = serde_json::from_value(properties).map_err(|error| error.to_string())?; if edited.handle() != source.handle() || edited.name() != source.name() { return Err("record identity is read-only".into()); } Ok((source, edited, paths)) } fn replace_table_entry(table: &mut Table, handle: acadrust::Handle, edited: T) { let target = table .iter_mut() .find(|entry| entry.handle() == handle) .expect("validated table entry"); *target = edited; } impl OpenCADStudio { pub(super) fn record_capabilities(&self) -> Value { let document = &self.tabs[self.active_tab].scene.document; json!({ "ok":true, "api":"ocs-cad-automation", "version":1, "concurrency":{"document_id":true,"revision":true,"request_id":true}, "transactions":{"undo":true,"redo":true,"atomic_property_updates":true,"batch":true}, "geometry":{"query":true,"kernel_measurements":true,"spatial_filters":true,"interactive_commands":true}, "records":{ "read":"records", "write":"set_properties", "describe":"record_schema", "path":"RFC 6901 JSON Pointer relative to properties", "filters":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"], "collections":COLLECTIONS.iter().map(|(name, mutable)|json!({ "name":name, "mutable":mutable, "root_type":collection_root_type(name), "count":collection_count(document, name), })).collect::>() }, "editor":{"selection":true,"properties":true,"commands":true,"files":true,"capture":true,"events":true} }) } pub(super) fn record_schema(&self, request: &Value) -> Value { let collection = request["collection"].as_str(); if collection.is_some_and(|name| collection_root_type(name).is_none()) { return failure( "unknown_collection", format!("unknown record collection: {}", collection.unwrap()), ); } let collections = COLLECTIONS .iter() .map(|(name, mutable)| { json!({ "name":name, "mutable":mutable, "root_type":collection_root_type(name), "identity_paths":identity_paths(name), }) }) .collect::>(); let Some(requested_type) = request["type"].as_str() else { let mut record_types = if let Some(collection) = collection { collection_record_types(collection) } else { registry_types() .iter() .map(|(name, info)| json!({"type":name,"schema_type":name,"kind":info["kind"]})) .collect() }; if let Some(search) = request["search"].as_str().filter(|value| !value.is_empty()) { let search = search.to_ascii_lowercase(); record_types.retain(|entry| { entry["type"] .as_str() .is_some_and(|name| name.to_ascii_lowercase().contains(&search)) }); } let count = record_types.len(); let offset = request["offset"].as_u64().unwrap_or(0) as usize; let limit = request["limit"].as_u64().unwrap_or(1000).min(10_000) as usize; let record_types = record_types .into_iter() .skip(offset) .take(limit) .collect::>(); return json!({ "ok":true, "schema_version":1, "collection":collection, "root_type":collection.and_then(collection_root_type), "collections":collections, "count":count, "returned":record_types.len(), "next_offset":(offset + record_types.len() < count).then_some(offset + record_types.len()), "record_types":record_types, "editing":editing_description(collection), "next":"Call record_schema again with type set to a record type or schema_type returned here." }); }; let root_type = collection .and_then(|collection| { collection_record_types(collection) .into_iter() .find(|entry| { ["type", "schema_type"].iter().any(|key| { entry[*key] .as_str() .is_some_and(|name| name.eq_ignore_ascii_case(requested_type)) }) }) .and_then(|entry| entry["schema_type"].as_str().map(str::to_string)) }) .or_else(|| resolved_type_name(requested_type)) .or_else(|| { (requested_type == "Value" || collection.and_then(collection_root_type) == Some("Value")) .then(|| "Value".to_string()) }); let Some(root_type) = root_type else { return failure( "unknown_record_type", format!("unknown record type: {requested_type}"), ); }; let mut fields = Vec::new(); let direct_collection_variant = collection .and_then(collection_root_type) .is_some_and(|collection_root| { collection_root == root_type && !requested_type.eq_ignore_ascii_case(&root_type) }) && registry_types() .get(&root_type) .is_some_and(|info| info["kind"] == "Enum"); if direct_collection_variant { append_variant_field_descriptions(&root_type, requested_type, collection, &mut fields); } else { append_field_descriptions(&root_type, "", collection, 0, &mut Vec::new(), &mut fields); } let definitions = referenced_definitions(&root_type); let runtime_schema = collection.and_then(|collection| { collection_records(&self.tabs[self.active_tab].scene.document, collection) .ok()? .into_iter() .find(|record| { let type_matches = record["type"] .as_str() .is_some_and(|name| name.eq_ignore_ascii_case(requested_type)) || collection_root_type(collection) .is_some_and(|name| name.eq_ignore_ascii_case(requested_type)); type_matches && request["handle"].as_str().is_none_or(|handle| { record["handle"].as_str().is_some_and(|actual| { actual.eq_ignore_ascii_case(without_hex_prefix(handle)) }) }) && request["name"].as_str().is_none_or(|name| { record["name"] .as_str() .is_some_and(|actual| actual.eq_ignore_ascii_case(name)) }) }) .map(|record| runtime_value_schema(&record["properties"], collection, "")) }); json!({ "ok":true, "schema_version":1, "collection":collection, "record_type":requested_type, "schema_type":root_type, "schema":registry_types().get(&root_type), "definitions":definitions, "fields":fields, "runtime_schema":runtime_schema, "editing":editing_description(collection), "notes":[ "Enum constraints list every accepted serialized variant.", "Integer constraints reflect the complete serialized range.", "Unit annotations are supplied only where the serialized field name and type are unambiguous.", "runtime_schema describes an existing matching record when one is available." ] }) } pub(super) fn record_query(&self, request: &Value) -> Value { let document = &self.tabs[self.active_tab].scene.document; let Some(collection) = request["collection"].as_str() else { return self.record_capabilities(); }; let mut records = Vec::new(); if collection == "all" { for (name, _) in COLLECTIONS { match collection_records(document, name) { Ok(mut values) => records.append(&mut values), Err(error) => return failure("serialization_failed", error), } } } else { match collection_records(document, collection) { Ok(values) => records = values, Err(error) => return failure("unknown_collection", error), } } let mut matched = Vec::new(); for record in records { match matches_filters(&record, request) { Ok(true) => matched.push(record), Ok(false) => {} Err(error) => return failure("invalid_filter", error), } } let count = matched.len(); let offset = request["offset"].as_u64().unwrap_or(0) as usize; let limit = request["limit"].as_u64().unwrap_or(1000).min(10_000) as usize; let mut returned = Vec::new(); for record in matched.into_iter().skip(offset).take(limit) { match project_paths(record, request["paths"].as_array()) { Ok(record) => returned.push(record), Err(error) => return failure("invalid_projection", error), } } json!({ "ok":true, "document_id":self.tabs[self.active_tab].id, "revision":self.tabs[self.active_tab].edit_revision, "geometry_revision":self.tabs[self.active_tab].scene.geometry_epoch, "collection":collection, "count":count, "returned":returned.len(), "next_offset":(offset + returned.len() < count).then_some(offset + returned.len()), "records":returned, }) } pub(super) fn control_set_record_properties( &mut self, request: &Value, ) -> Result, Value> { let collection = request["collection"] .as_str() .ok_or_else(|| failure("collection_required", "set_properties requires collection"))?; if !COLLECTIONS .iter() .any(|(name, mutable)| *name == collection && *mutable) { return Err(failure( "read_only_collection", format!("collection is absent or read-only: {collection}"), )); } let i = self.active_tab; let changed; let mut result_handle = None; let mut result_name = None; let paths; match collection { "entities" => { let handle = requested_handle(request).map_err(|error| failure("invalid_handle", error))?; if self.tabs[i].scene.is_layer_locked(handle) { return Err(failure("layer_locked", "entity is on a locked layer")); } let source = self.tabs[i] .scene .document .get_entity(handle) .cloned() .ok_or_else(|| failure("record_absent", "entity does not exist"))?; let (kind, mut properties) = enum_parts(&source).map_err(|error| failure("serialization_failed", error))?; paths = apply_updates(&mut properties, request, collection) .map_err(|error| failure("invalid_update", error))?; let mut edited: acadrust::EntityType = decode_enum(&kind, properties) .map_err(|error| failure("invalid_value", error))?; if edited.common().handle != handle { return Err(failure("identity_changed", "entity identity is read-only")); } edited.preserve_storage_data_from(&source); changed = edited != source; if changed { self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); if !self.tabs[i].scene.update_entity(edited) { self.discard_last_undo_entry(i); return Err(failure("update_failed", "entity could not be updated")); } self.tabs[i].dirty = true; } result_handle = Some(handle_text(handle)); } "objects" => { let handle = requested_handle(request).map_err(|error| failure("invalid_handle", error))?; let source = self.tabs[i] .scene .document .objects .get(&handle) .cloned() .ok_or_else(|| failure("record_absent", "object does not exist"))?; let (kind, mut properties) = enum_parts(&source).map_err(|error| failure("serialization_failed", error))?; paths = apply_updates(&mut properties, request, collection) .map_err(|error| failure("invalid_update", error))?; let mut edited: acadrust::objects::ObjectType = decode_enum(&kind, properties) .map_err(|error| failure("invalid_value", error))?; edited.preserve_storage_data_from(&source); changed = edited != source; if changed { self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); self.tabs[i].scene.document.objects.insert(handle, edited); self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; } result_handle = Some(handle_text(handle)); } "header" => { let source = self.tabs[i].scene.document.header.clone(); let mut properties = json_value(&source).map_err(|error| failure("serialization_failed", error))?; paths = apply_updates(&mut properties, request, collection) .map_err(|error| failure("invalid_update", error))?; let edited = serde_json::from_value(properties).map_err(|error: serde_json::Error| { failure("invalid_value", error.to_string()) })?; changed = edited != source; if changed { self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); self.tabs[i].scene.document.header = edited; self.tabs[i].adopt_active_ucs_from_header(); self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; } result_name = Some("header".to_string()); } "summary_info" => { let source = self.tabs[i].scene.document.summary_info.clone(); let mut properties = json_value(&source).map_err(|error| failure("serialization_failed", error))?; paths = apply_updates(&mut properties, request, collection) .map_err(|error| failure("invalid_update", error))?; let edited = serde_json::from_value(properties).map_err(|error: serde_json::Error| { failure("invalid_value", error.to_string()) })?; changed = edited != source; if changed { self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); self.tabs[i].scene.document.summary_info = edited; self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; } result_name = Some("summary_info".to_string()); } _ => { macro_rules! patch_table { ($field:ident) => {{ let (source, edited, changed_paths) = patched_table_entry( &self.tabs[i].scene.document.$field, request, collection, ) .map_err(|error| failure("invalid_update", error))?; paths = changed_paths; changed = edited != source; let handle = source.handle(); result_handle = Some(handle_text(handle)); result_name = Some(source.name().to_string()); if changed { self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); replace_table_entry( &mut self.tabs[i].scene.document.$field, handle, edited, ); self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; } }}; } match collection { "layers" => patch_table!(layers), "line_types" => patch_table!(line_types), "text_styles" => patch_table!(text_styles), "block_records" => patch_table!(block_records), "dim_styles" => patch_table!(dim_styles), "app_ids" => patch_table!(app_ids), "views" => patch_table!(views), "vports" => patch_table!(vports), "ucss" => patch_table!(ucss), "vx_table" => patch_table!(vx_table), _ => unreachable!(), } } } if changed { self.refresh_properties(); } self.set_control_result(json!({ "collection":collection, "handle":result_handle, "name":result_name, "paths":paths, "changed":changed, })); Ok(Task::none()) } } #[cfg(test)] mod tests { use acadrust::entities::{AttributeEntity, EntityType, Insert}; use acadrust::objects::{Dictionary, ObjectType}; use acadrust::types::{Handle, Vector3}; use serde_json::{Value, json}; use super::OpenCADStudio; fn execute(app: &mut OpenCADStudio, mut request: Value, id: &str) -> Value { let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); let object = request.as_object_mut().unwrap(); object.insert("protocol".into(), json!(1)); object.insert("request_id".into(), json!(id)); object.insert("document_id".into(), state["document_id"].clone()); object.insert("revision".into(), state["revision"].clone()); app.automation_op(&request.to_string()) } #[test] fn records_query_and_edit_every_mutable_record_family() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let i = app.active_tab; let mut insert = Insert::new("A_CPT", Vector3::new(12.0, 34.0, 5.0)); insert .attributes .push(AttributeEntity::simple("COMPANY", "BPH")); let entity = app.tabs[i].scene.add_entity(EntityType::Insert(insert)); let object_handle = Handle::new(0x500); let mut dictionary = Dictionary::new(); dictionary.handle = object_handle; app.tabs[i] .scene .document .objects .insert(object_handle, ObjectType::Dictionary(dictionary)); let records = app.record_query(&json!({ "collection":"entities", "type":"Insert", "where":[{"path":"/attributes/0/tag","value":"COMPANY"}] })); assert_eq!(records["count"], 1); assert_eq!(records["records"][0]["properties"]["block_name"], "A_CPT"); assert_eq!( records["records"][0]["properties"]["attributes"][0]["value"], "BPH" ); let edited = execute( &mut app, json!({ "op":"set_properties", "collection":"entities", "handle":format!("{:X}", entity.value()), "updates":[ {"path":"/attributes/0/value","expected":"BPH","value":"Edited"}, {"path":"/insert_point/x","value":20.0} ] }), "edit-entity", ); assert_eq!(edited["status"], "completed", "{edited}"); assert_eq!(edited["result"]["changed"], true); let EntityType::Insert(insert) = app.tabs[i].scene.document.get_entity(entity).unwrap() else { panic!("expected insert") }; assert_eq!(insert.attributes[0].value, "Edited"); assert_eq!(insert.insert_point.x, 20.0); let object_edit = execute( &mut app, json!({ "op":"set_properties", "collection":"objects", "handle":"500", "updates":[{"path":"/hard_owner","value":true}] }), "edit-object", ); assert_eq!(object_edit["result"]["changed"], true, "{object_edit}"); let Some(ObjectType::Dictionary(dictionary)) = app.tabs[i].scene.document.objects.get(&object_handle) else { panic!("expected dictionary") }; assert!(dictionary.hard_owner); let layer_edit = execute( &mut app, json!({ "op":"set_properties", "collection":"layers", "name":"0", "updates":[{"path":"/is_plottable","value":false}] }), "edit-layer", ); assert_eq!(layer_edit["result"]["changed"], true, "{layer_edit}"); assert!( !app.tabs[i] .scene .document .layers .get("0") .unwrap() .is_plottable ); let header_edit = execute( &mut app, json!({ "op":"set_properties", "collection":"header", "updates":[{"path":"/point_display_size","value":7.5}] }), "edit-header", ); assert_eq!(header_edit["result"]["changed"], true, "{header_edit}"); assert_eq!(app.tabs[i].scene.document.header.point_display_size, 7.5); let summary_edit = execute( &mut app, json!({ "op":"set_properties", "collection":"summary_info", "updates":[{"path":"/author","value":"MCP"}] }), "edit-summary", ); assert_eq!(summary_edit["result"]["changed"], true, "{summary_edit}"); assert_eq!(app.tabs[i].scene.document.summary_info.author, "MCP"); let undo = execute(&mut app, json!({"op":"undo"}), "undo-summary"); assert_eq!(undo["status"], "completed", "{undo}"); assert!(app.tabs[i].scene.document.summary_info.author.is_empty()); } #[test] fn record_schema_describes_absent_types_fields_enums_units_and_write_rules() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let catalog = app.record_schema(&json!({"collection":"entities"})); assert_eq!(catalog["ok"], true, "{catalog}"); assert_eq!(catalog["root_type"], "EntityType"); assert_eq!(catalog["count"], 48); assert!(catalog["record_types"] .as_array() .unwrap() .iter() .any(|record| record["type"] == "Insert" && record["schema_type"] == "Insert")); let schema = app.record_schema(&json!({ "collection":"entities", "type":"Insert" })); assert_eq!(schema["ok"], true, "{schema}"); assert!(schema["definitions"].get("Insert").is_some()); assert!(schema["definitions"].get("AttributeEntity").is_some()); assert!(schema["runtime_schema"].is_null()); let fields = schema["fields"].as_array().unwrap(); let field = |path: &str| { fields .iter() .find(|field| field["path"] == path) .unwrap_or_else(|| panic!("missing schema field {path}")) }; assert_eq!(field("/common/handle")["writable"], false); assert_eq!(field("/insert_point")["unit"], "drawing_units"); assert_eq!(field("/rotation")["unit"], "radians"); assert_eq!( field("/attributes")["item_path_template"], "/attributes/{index}" ); assert_eq!(field("/attributes/{index}/value")["writable"], true); let text = app.record_schema(&json!({"collection":"entities","type":"Text"})); let alignment = text["fields"] .as_array() .unwrap() .iter() .find(|field| field["path"] == "/horizontal_alignment") .expect("horizontal alignment schema"); assert!(alignment["constraints"]["allowed_variants"] .as_array() .is_some_and(|variants| variants.len() >= 3)); let unknown = app.record_schema(&json!({"collection":"objects","type":"Unknown"})); assert_eq!(unknown["schema_type"], "ObjectType"); let fields = unknown["fields"].as_array().unwrap(); let field = |path: &str| { fields .iter() .find(|field| field["path"] == path) .unwrap_or_else(|| panic!("missing enum variant field {path}")) }; assert_eq!(field("/type_name")["writable"], true); assert_eq!(field("/handle")["writable"], false); assert_eq!(field("/owner")["writable"], false); assert_eq!(field("/raw_dwg_handle_bits")["writable"], true); let dimension = app.record_schema(&json!({"collection":"entities","type":"Dimension"})); let fields = dimension["fields"].as_array().unwrap(); assert!(fields.iter().any(|field| field["path"] == "/Linear")); assert!(fields.iter().any( |field| field["path"] == "/Linear/base/common/handle" && field["writable"] == false )); assert!(fields.iter().any(|field| field["path"] == "/Radius")); let section_style = app .record_schema(&json!({"collection":"section_view_style","type":"SectionViewStyle"})); assert_eq!(section_style["schema_type"], "EntitySectionViewStyle"); assert!(section_style["fields"] .as_array() .is_some_and(|fields| fields .iter() .any(|field| field["path"] == "/arrow_size" && field["unit"] == "drawing_units"))); let class = app.record_schema(&json!({"collection":"classes","type":"Class"})); assert_eq!(class["schema_type"], "DxfClass"); assert!(class["fields"] .as_array() .is_some_and(|fields| fields.iter().any(|field| field["path"] == "/dxf_name"))); for (collection, root) in [("entities", "EntityType"), ("objects", "ObjectType")] { let complete = app.record_schema(&json!({"collection":collection,"type":root})); assert_eq!(complete["ok"], true, "{root}: {complete}"); assert!(complete["fields"] .as_array() .is_some_and(|fields| !fields.is_empty())); let bytes = serde_json::to_vec(&complete).unwrap().len(); assert!(bytes < 8 * 1024 * 1024, "{root} schema is {bytes} bytes"); } } #[test] fn record_updates_reject_type_identity_and_compare_failures() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let i = app.active_tab; let entity = app.tabs[i] .scene .add_entity(EntityType::Insert(Insert::new("A", Vector3::ZERO))); let handle = format!("{:X}", entity.value()); let wrong_type = execute( &mut app, json!({"op":"set_properties","collection":"entities","handle":handle, "updates":[{"path":"/rotation","value":"invalid"}]}), "wrong-type", ); assert_eq!(wrong_type["code"], "invalid_value", "{wrong_type}"); let identity = execute( &mut app, json!({"op":"set_properties","collection":"entities","handle":handle, "updates":[{"path":"/common/handle","value":99}]}), "identity", ); assert_eq!(identity["code"], "invalid_update", "{identity}"); let identity_parent = execute( &mut app, json!({"op":"set_properties","collection":"entities","handle":handle, "updates":[{"path":"/common","value":{}}]}), "identity-parent", ); assert_eq!( identity_parent["code"], "invalid_update", "{identity_parent}" ); let compare = execute( &mut app, json!({"op":"set_properties","collection":"entities","handle":handle, "updates":[{"path":"/rotation","expected":1.0,"value":2.0}]}), "compare", ); assert_eq!(compare["code"], "invalid_update", "{compare}"); } }