robomotion 0.1.3

Official Rust SDK for building Robomotion RPA packages
Documentation
//! Node specification generation.

use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;

/// Node specification for the Designer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeSpecJson {
    pub id: String,
    pub icon: String,
    pub name: String,
    pub color: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub editor: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub spec: Option<String>,
    pub inputs: usize,
    pub outputs: usize,
    #[serde(rename = "inFilters", skip_serializing_if = "Option::is_none")]
    pub in_filters: Option<String>,
    pub properties: Vec<PropertyJson>,
    #[serde(rename = "customPorts", skip_serializing_if = "Option::is_none")]
    pub custom_ports: Option<Vec<Value>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool: Option<Value>,
}

/// Property specification for the Designer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PropertyJson {
    pub schema: SchemaJson,
    #[serde(rename = "formData")]
    pub form_data: HashMap<String, Value>,
    #[serde(rename = "uiSchema")]
    pub ui_schema: HashMap<String, Value>,
}

/// JSON Schema for a property group.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaJson {
    #[serde(rename = "type")]
    pub schema_type: String,
    pub title: String,
    pub properties: HashMap<String, SchemaPropertyJson>,
}

/// JSON Schema for a single property.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaPropertyJson {
    #[serde(rename = "type")]
    pub property_type: String,
    pub title: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub subtitle: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub category: Option<i32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub properties: Option<HashMap<String, Value>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub items: Option<HashMap<String, Value>>,
    #[serde(rename = "csScope", skip_serializing_if = "Option::is_none")]
    pub cs_scope: Option<bool>,
    #[serde(rename = "jsScope", skip_serializing_if = "Option::is_none")]
    pub js_scope: Option<bool>,
    #[serde(rename = "customScope", skip_serializing_if = "Option::is_none")]
    pub custom_scope: Option<bool>,
    #[serde(rename = "messageScope", skip_serializing_if = "Option::is_none")]
    pub message_scope: Option<bool>,
    #[serde(rename = "messageOnly", skip_serializing_if = "Option::is_none")]
    pub message_only: Option<bool>,
    #[serde(rename = "aiScope", skip_serializing_if = "Option::is_none")]
    pub ai_scope: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub multiple: Option<bool>,
    #[serde(rename = "variableType", skip_serializing_if = "Option::is_none")]
    pub variable_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub format: Option<String>,
    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
    pub enum_values: Option<Vec<Value>>,
    #[serde(rename = "enumNames", skip_serializing_if = "Option::is_none")]
    pub enum_names: Option<Vec<String>>,
}

/// Trait for types that can generate a node specification.
pub trait NodeSpec {
    /// Get the node ID (e.g., "Namespace.NodeName").
    fn node_id() -> &'static str;

    /// Get the display name.
    fn node_name() -> &'static str;

    /// Get the icon name.
    fn node_icon() -> &'static str;

    /// Get the node color.
    fn node_color() -> &'static str;

    /// Get the number of input ports.
    fn inputs() -> usize {
        1
    }

    /// Get the number of output ports.
    fn outputs() -> usize {
        1
    }

    /// Get the tool specification if this is a tool node.
    fn tool_spec() -> Option<super::ToolSpec> {
        None
    }

    /// Get the property specifications.
    fn properties() -> Vec<PropertySpec>;
}

/// Property type (Input, Output, or Option).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PropertyType {
    Input,
    Output,
    Option,
}

/// Property specification for a single field.
#[derive(Debug, Clone)]
pub struct PropertySpec {
    pub field_name: String,
    pub title: String,
    pub var_type: String,
    pub scope: String,
    pub name: String,
    pub default_value: String,
    pub property_type: PropertyType,
    pub is_variable: bool,
    pub description: Option<String>,
    /// Enable Message scope selection in Designer
    pub message_scope: bool,
    /// Enable JavaScript scope selection in Designer
    pub js_scope: bool,
    /// Enable Custom scope selection in Designer
    pub custom_scope: bool,
    /// Enable AI scope selection in Designer
    pub ai_scope: bool,
}

/// Generate the spec file content for registered nodes.
pub fn generate_spec_file(plugin_name: &str, version: &str) -> String {
    use crate::runtime::registered_node_specs;

    let nodes: Vec<NodeSpecJson> = registered_node_specs()
        .into_iter()
        .map(|spec| spec_to_json(spec))
        .collect();

    let data = serde_json::json!({
        "name": plugin_name,
        "version": version,
        "nodes": nodes,
    });

    serde_json::to_string_pretty(&data).unwrap_or_else(|_| "{}".to_string())
}

/// Convert a node spec to JSON representation.
fn spec_to_json(spec: RegisteredNodeSpec) -> NodeSpecJson {
    let mut in_property = PropertyJson {
        schema: SchemaJson {
            schema_type: "object".to_string(),
            title: "Input".to_string(),
            properties: HashMap::new(),
        },
        form_data: HashMap::new(),
        ui_schema: HashMap::new(),
    };
    in_property.ui_schema.insert("ui:order".to_string(), Value::Array(vec![]));

    let mut out_property = PropertyJson {
        schema: SchemaJson {
            schema_type: "object".to_string(),
            title: "Output".to_string(),
            properties: HashMap::new(),
        },
        form_data: HashMap::new(),
        ui_schema: HashMap::new(),
    };
    out_property.ui_schema.insert("ui:order".to_string(), Value::Array(vec![]));

    let mut opt_property = PropertyJson {
        schema: SchemaJson {
            schema_type: "object".to_string(),
            title: "Options".to_string(),
            properties: HashMap::new(),
        },
        form_data: HashMap::new(),
        ui_schema: HashMap::new(),
    };
    opt_property.ui_schema.insert("ui:order".to_string(), Value::Array(vec![]));

    for prop in spec.properties {
        let schema_prop = SchemaPropertyJson {
            property_type: if prop.is_variable { "object".to_string() } else { prop.var_type.to_lowercase() },
            title: prop.title.clone(),
            description: prop.description.clone(),
            subtitle: None,
            category: None,
            properties: if prop.is_variable {
                Some(HashMap::from([
                    ("scope".to_string(), serde_json::json!({"type": "string"})),
                    ("name".to_string(), serde_json::json!({"type": "string"})),
                ]))
            } else {
                None
            },
            items: None,
            cs_scope: None,
            js_scope: if prop.js_scope { Some(true) } else { None },
            custom_scope: if prop.custom_scope { Some(true) } else { None },
            message_scope: if prop.message_scope { Some(true) } else { None },
            message_only: None,
            ai_scope: if prop.ai_scope { Some(true) } else { None },
            multiple: None,
            variable_type: if prop.is_variable { Some(capitalize_first(&prop.var_type)) } else { None },
            format: None,
            enum_values: None,
            enum_names: None,
        };

        let form_value = if prop.is_variable {
            serde_json::json!({
                "scope": prop.scope,
                "name": prop.name,
            })
        } else {
            serde_json::json!(prop.default_value)
        };

        let ui_value = if prop.is_variable {
            serde_json::json!({"ui:field": "variable"})
        } else {
            Value::Null
        };

        match prop.property_type {
            PropertyType::Input => {
                in_property.schema.properties.insert(prop.field_name.clone(), schema_prop);
                in_property.form_data.insert(prop.field_name.clone(), form_value);
                if !ui_value.is_null() {
                    in_property.ui_schema.insert(prop.field_name.clone(), ui_value);
                }
                if let Some(Value::Array(ref mut arr)) = in_property.ui_schema.get_mut("ui:order") {
                    arr.push(Value::String(prop.field_name));
                }
            }
            PropertyType::Output => {
                out_property.schema.properties.insert(prop.field_name.clone(), schema_prop);
                out_property.form_data.insert(prop.field_name.clone(), form_value);
                if !ui_value.is_null() {
                    out_property.ui_schema.insert(prop.field_name.clone(), ui_value);
                }
                if let Some(Value::Array(ref mut arr)) = out_property.ui_schema.get_mut("ui:order") {
                    arr.push(Value::String(prop.field_name));
                }
            }
            PropertyType::Option => {
                opt_property.schema.properties.insert(prop.field_name.clone(), schema_prop);
                opt_property.form_data.insert(prop.field_name.clone(), form_value);
                if !ui_value.is_null() {
                    opt_property.ui_schema.insert(prop.field_name.clone(), ui_value);
                }
                if let Some(Value::Array(ref mut arr)) = opt_property.ui_schema.get_mut("ui:order") {
                    arr.push(Value::String(prop.field_name));
                }
            }
        }
    }

    let mut properties = vec![];
    if !in_property.schema.properties.is_empty() {
        properties.push(in_property);
    }
    if !out_property.schema.properties.is_empty() {
        properties.push(out_property);
    }
    if !opt_property.schema.properties.is_empty() {
        properties.push(opt_property);
    }

    let tool = spec.tool.map(|t| serde_json::json!({
        "name": t.name,
        "description": t.description,
    }));

    NodeSpecJson {
        id: spec.id,
        icon: spec.icon,
        name: spec.name,
        color: spec.color,
        editor: None,
        spec: None,
        inputs: spec.inputs,
        outputs: spec.outputs,
        in_filters: None,
        properties,
        custom_ports: None,
        tool,
    }
}

fn capitalize_first(s: &str) -> String {
    let mut c = s.chars();
    match c.next() {
        None => String::new(),
        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
    }
}

/// Registered node specification.
pub struct RegisteredNodeSpec {
    pub id: String,
    pub name: String,
    pub icon: String,
    pub color: String,
    pub inputs: usize,
    pub outputs: usize,
    pub tool: Option<super::ToolSpec>,
    pub properties: Vec<PropertySpec>,
}