Skip to main content

rustbrain_core/obsidian/
canvas.rs

1//! Obsidian Canvas (`.canvas`) JSON parsing.
2
3use serde::{Deserialize, Serialize};
4
5/// Errors parsing Canvas documents.
6#[derive(Debug, thiserror::Error)]
7pub enum CanvasError {
8    /// JSON deserialization failed.
9    #[error("failed to parse Obsidian .canvas JSON: {0}")]
10    Json(#[from] serde_json::Error),
11}
12
13/// An Obsidian Canvas (`.canvas`) document.
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct ObsidianCanvas {
16    /// Canvas nodes.
17    #[serde(default)]
18    pub nodes: Vec<CanvasNode>,
19    /// Canvas edges.
20    #[serde(default)]
21    pub edges: Vec<CanvasEdge>,
22}
23
24/// A node on an Obsidian canvas.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CanvasNode {
27    /// Node id (canvas-local).
28    pub id: String,
29    /// Node type: `text`, `file`, `link`, `group`.
30    #[serde(rename = "type")]
31    pub node_type: String,
32    /// Text content for text nodes.
33    pub text: Option<String>,
34    /// File path for file nodes.
35    pub file: Option<String>,
36    /// URL for link nodes.
37    pub url: Option<String>,
38    /// X position.
39    pub x: f64,
40    /// Y position.
41    pub y: f64,
42    /// Width.
43    pub width: f64,
44    /// Height.
45    pub height: f64,
46}
47
48/// An edge on an Obsidian canvas.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct CanvasEdge {
51    /// Edge id.
52    pub id: String,
53    /// Source canvas node id.
54    #[serde(rename = "fromNode")]
55    pub from_node: String,
56    /// Target canvas node id.
57    #[serde(rename = "toNode")]
58    pub to_node: String,
59    /// Optional label (used as relation type).
60    pub label: Option<String>,
61    /// Optional from side.
62    #[serde(rename = "fromSide")]
63    pub from_side: Option<String>,
64    /// Optional to side.
65    #[serde(rename = "toSide")]
66    pub to_side: Option<String>,
67}
68
69impl ObsidianCanvas {
70    /// Parse an Obsidian `.canvas` JSON document.
71    pub fn parse_str(json_content: &str) -> Result<Self, CanvasError> {
72        Ok(serde_json::from_str::<Self>(json_content)?)
73    }
74
75    /// Extract relational triples `(source, target, relation_type)` from canvas edges.
76    ///
77    /// File nodes resolve to their path stem (without `.md`); text nodes use the
78    /// first heading line when present.
79    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}