use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum CanvasError {
#[error("failed to parse Obsidian .canvas JSON: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ObsidianCanvas {
#[serde(default)]
pub nodes: Vec<CanvasNode>,
#[serde(default)]
pub edges: Vec<CanvasEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasNode {
pub id: String,
#[serde(rename = "type")]
pub node_type: String,
pub text: Option<String>,
pub file: Option<String>,
pub url: Option<String>,
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasEdge {
pub id: String,
#[serde(rename = "fromNode")]
pub from_node: String,
#[serde(rename = "toNode")]
pub to_node: String,
pub label: Option<String>,
#[serde(rename = "fromSide")]
pub from_side: Option<String>,
#[serde(rename = "toSide")]
pub to_side: Option<String>,
}
impl ObsidianCanvas {
pub fn parse_str(json_content: &str) -> Result<Self, CanvasError> {
Ok(serde_json::from_str::<Self>(json_content)?)
}
pub fn extract_relationships(&self) -> Vec<(String, String, String)> {
let mut relationships = Vec::new();
let mut id_map = std::collections::HashMap::new();
for node in &self.nodes {
let target_name = if let Some(file) = &node.file {
file.trim_end_matches(".md").to_string()
} else if let Some(text) = &node.text {
text.lines()
.next()
.unwrap_or(&node.id)
.trim_start_matches("# ")
.to_string()
} else {
node.id.clone()
};
id_map.insert(node.id.as_str(), target_name);
}
for edge in &self.edges {
if let (Some(src), Some(dst)) = (
id_map.get(edge.from_node.as_str()),
id_map.get(edge.to_node.as_str()),
) {
let rel = edge
.label
.clone()
.unwrap_or_else(|| "relates_to".to_string());
relationships.push((src.clone(), dst.clone(), rel));
}
}
relationships
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_obsidian_canvas() {
let json = r##"{
"nodes": [
{ "id": "n1", "type": "file", "file": "concepts/raft.md", "x": 0, "y": 0, "width": 200, "height": 100 },
{ "id": "n2", "type": "text", "text": "# Log Compaction", "x": 300, "y": 0, "width": 200, "height": 100 }
],
"edges": [
{ "id": "e1", "fromNode": "n1", "toNode": "n2", "label": "implements" }
]
}"##;
let canvas = ObsidianCanvas::parse_str(json).unwrap();
assert_eq!(canvas.nodes.len(), 2);
assert_eq!(canvas.edges.len(), 1);
let rels = canvas.extract_relationships();
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].0, "concepts/raft");
assert_eq!(rels[0].1, "Log Compaction");
assert_eq!(rels[0].2, "implements");
}
}