rustbrain_core/obsidian/
canvas.rs1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, thiserror::Error)]
7pub enum CanvasError {
8 #[error("failed to parse Obsidian .canvas JSON: {0}")]
10 Json(#[from] serde_json::Error),
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct ObsidianCanvas {
16 #[serde(default)]
18 pub nodes: Vec<CanvasNode>,
19 #[serde(default)]
21 pub edges: Vec<CanvasEdge>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CanvasNode {
27 pub id: String,
29 #[serde(rename = "type")]
31 pub node_type: String,
32 pub text: Option<String>,
34 pub file: Option<String>,
36 pub url: Option<String>,
38 pub x: f64,
40 pub y: f64,
42 pub width: f64,
44 pub height: f64,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct CanvasEdge {
51 pub id: String,
53 #[serde(rename = "fromNode")]
55 pub from_node: String,
56 #[serde(rename = "toNode")]
58 pub to_node: String,
59 pub label: Option<String>,
61 #[serde(rename = "fromSide")]
63 pub from_side: Option<String>,
64 #[serde(rename = "toSide")]
66 pub to_side: Option<String>,
67}
68
69impl ObsidianCanvas {
70 pub fn parse_str(json_content: &str) -> Result<Self, CanvasError> {
72 Ok(serde_json::from_str::<Self>(json_content)?)
73 }
74
75 pub fn extract_relationships(&self) -> Vec<(String, String, String)> {
80 let mut relationships = Vec::new();
81
82 let mut id_map = std::collections::HashMap::new();
83 for node in &self.nodes {
84 let target_name = if let Some(file) = &node.file {
85 file.trim_end_matches(".md").to_string()
86 } else if let Some(text) = &node.text {
87 text.lines()
88 .next()
89 .unwrap_or(&node.id)
90 .trim_start_matches("# ")
91 .to_string()
92 } else {
93 node.id.clone()
94 };
95 id_map.insert(node.id.as_str(), target_name);
96 }
97
98 for edge in &self.edges {
99 if let (Some(src), Some(dst)) = (
100 id_map.get(edge.from_node.as_str()),
101 id_map.get(edge.to_node.as_str()),
102 ) {
103 let rel = edge
104 .label
105 .clone()
106 .unwrap_or_else(|| "relates_to".to_string());
107 relationships.push((src.clone(), dst.clone(), rel));
108 }
109 }
110
111 relationships
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn parse_obsidian_canvas() {
121 let json = r##"{
122 "nodes": [
123 { "id": "n1", "type": "file", "file": "concepts/raft.md", "x": 0, "y": 0, "width": 200, "height": 100 },
124 { "id": "n2", "type": "text", "text": "# Log Compaction", "x": 300, "y": 0, "width": 200, "height": 100 }
125 ],
126 "edges": [
127 { "id": "e1", "fromNode": "n1", "toNode": "n2", "label": "implements" }
128 ]
129 }"##;
130
131 let canvas = ObsidianCanvas::parse_str(json).unwrap();
132 assert_eq!(canvas.nodes.len(), 2);
133 assert_eq!(canvas.edges.len(), 1);
134
135 let rels = canvas.extract_relationships();
136 assert_eq!(rels.len(), 1);
137 assert_eq!(rels[0].0, "concepts/raft");
138 assert_eq!(rels[0].1, "Log Compaction");
139 assert_eq!(rels[0].2, "implements");
140 }
141}