Skip to main content

bnto_core/editor/
convert.rs

1// Definition ↔ EditorModel conversion and utility functions.
2
3use std::collections::HashMap;
4
5use crate::definition::{Definition, Edge, Metadata, Position};
6use crate::pipeline::{IterationMode, PipelineSettings};
7use crate::{FORMAT_VERSION, definition};
8
9use super::types::{EditorModel, EditorNode, EditorSource};
10
11impl EditorModel {
12    /// Build an editor model from a parsed `Definition`.
13    pub fn from_definition(def: &Definition, source: EditorSource) -> Self {
14        let nodes: Vec<EditorNode> = def
15            .nodes
16            .as_ref()
17            .map(|defs| {
18                defs.iter()
19                    .map(|d| EditorNode {
20                        id: d.id.clone(),
21                        node_type: d.node_type.clone(),
22                        label: d.name.clone(),
23                        params: json_to_params(&d.parameters),
24                        expanded: false,
25                    })
26                    .collect()
27            })
28            .unwrap_or_default();
29
30        let selected_index = if nodes.is_empty() { None } else { Some(0) };
31
32        Self {
33            recipe_name: def.name.clone(),
34            recipe_description: def.metadata.description.clone().unwrap_or_default(),
35            nodes,
36            selected_index,
37            dirty: false,
38            undo_stack: Vec::new(),
39            redo_stack: Vec::new(),
40            source,
41        }
42    }
43
44    /// Serialize the editor state back to a `Definition`.
45    pub fn to_definition(&self) -> Definition {
46        let child_defs: Vec<Definition> = self
47            .nodes
48            .iter()
49            .enumerate()
50            .map(|(i, node)| Definition {
51                id: node.id.clone(),
52                node_type: node.node_type.clone(),
53                version: FORMAT_VERSION.to_string(),
54                parent_id: None,
55                name: node.label.clone(),
56                position: Position {
57                    x: 0.0,
58                    y: (i as f64) * 100.0,
59                },
60                metadata: Metadata::default(),
61                parameters: params_to_json(&node.params),
62                input_ports: vec![],
63                output_ports: vec![],
64                nodes: None,
65                edges: None,
66                settings: None,
67                requires: Vec::new(),
68                fields: std::collections::BTreeMap::new(),
69            })
70            .collect();
71
72        let edges: Vec<Edge> = child_defs
73            .windows(2)
74            .enumerate()
75            .map(|(i, pair)| Edge {
76                id: format!("e{}", i + 1),
77                source: pair[0].id.clone(),
78                target: pair[1].id.clone(),
79                source_handle: None,
80                target_handle: None,
81            })
82            .collect();
83
84        Definition {
85            id: slug_from_name(&self.recipe_name),
86            node_type: "group".to_string(),
87            version: FORMAT_VERSION.to_string(),
88            parent_id: None,
89            name: self.recipe_name.clone(),
90            position: Position { x: 0.0, y: 0.0 },
91            metadata: Metadata {
92                description: if self.recipe_description.is_empty() {
93                    None
94                } else {
95                    Some(self.recipe_description.clone())
96                },
97                ..Default::default()
98            },
99            parameters: definition::default_parameters(),
100            input_ports: vec![],
101            output_ports: vec![],
102            nodes: Some(child_defs),
103            edges: Some(edges),
104            settings: Some(PipelineSettings {
105                iteration: IterationMode::Auto,
106            }),
107            requires: Vec::new(),
108            fields: std::collections::BTreeMap::new(),
109        }
110    }
111}
112
113/// Convert a `serde_json::Value` object into a `HashMap<String, Value>`.
114pub(crate) fn json_to_params(value: &serde_json::Value) -> HashMap<String, serde_json::Value> {
115    match value.as_object() {
116        Some(map) => map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
117        None => HashMap::new(),
118    }
119}
120
121/// Convert a `HashMap<String, Value>` back to a `serde_json::Value` object.
122pub(crate) fn params_to_json(params: &HashMap<String, serde_json::Value>) -> serde_json::Value {
123    let map: serde_json::Map<String, serde_json::Value> =
124        params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
125    serde_json::Value::Object(map)
126}
127
128/// Generate a URL-safe slug from a recipe name.
129pub(crate) fn slug_from_name(name: &str) -> String {
130    name.to_lowercase()
131        .chars()
132        .map(|c| if c.is_alphanumeric() { c } else { '-' })
133        .collect::<String>()
134        .split('-')
135        .filter(|s| !s.is_empty())
136        .collect::<Vec<_>>()
137        .join("-")
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn slug_from_name_produces_valid_slug() {
146        assert_eq!(slug_from_name("My Great Recipe"), "my-great-recipe");
147        assert_eq!(slug_from_name(""), "");
148        assert_eq!(slug_from_name("hello"), "hello");
149        assert_eq!(slug_from_name("A  B"), "a-b");
150        assert_eq!(slug_from_name("Compress & Resize!"), "compress-resize");
151    }
152
153    #[test]
154    fn json_to_params_handles_object() {
155        use serde_json::json;
156        let val = json!({"quality": 80, "format": "webp"});
157        let params = json_to_params(&val);
158        assert_eq!(params.get("quality"), Some(&json!(80)));
159        assert_eq!(params.get("format"), Some(&json!("webp")));
160    }
161
162    #[test]
163    fn json_to_params_handles_non_object() {
164        use serde_json::json;
165        let val = json!(42);
166        let params = json_to_params(&val);
167        assert!(params.is_empty());
168    }
169}