Skip to main content

mneme/export/
obsidian.rs

1use std::path::Path;
2
3use crate::store::memory::{Memory, MemoryRelation, MemoryType};
4use chrono::Utc;
5
6/// Exporta el grafo de conocimiento de un proyecto a un vault de Obsidian.
7///
8/// Cada memoria se exporta como un archivo .md individual con:
9/// - Frontmatter YAML con metadatos
10/// - [[wikilinks]] a memorias relacionadas
11/// - Tags de Obsidian
12pub fn export_to_obsidian(
13    memories: &[Memory],
14    relations: &[MemoryRelation],
15    project: &str,
16    output_dir: &Path,
17) -> crate::error::Result<ObsidianExportStats> {
18    let vault_root = output_dir.join("mneme-export");
19    let memories_dir = vault_root.join("memories");
20    let graph_dir = vault_root.join(".graph");
21
22    std::fs::create_dir_all(&memories_dir)?;
23    std::fs::create_dir_all(&graph_dir)?;
24
25    let mut stats = ObsidianExportStats::default();
26
27    // Build relation map: memory_id -> linked memory ids
28    let mut link_map: std::collections::HashMap<String, Vec<(String, String)>> =
29        std::collections::HashMap::new();
30    for rel in relations {
31        link_map
32            .entry(rel.source_id.to_string())
33            .or_default()
34            .push((rel.target_id.to_string(), rel.relation_type.to_string()));
35        // Bidirectional for Obsidian navigation
36        link_map
37            .entry(rel.target_id.to_string())
38            .or_default()
39            .push((rel.source_id.to_string(), rel.relation_type.to_string()));
40    }
41
42    // Build title map for wikilinks
43    let title_map: std::collections::HashMap<String, &Memory> =
44        memories.iter().map(|m| (m.id.to_string(), m)).collect();
45
46    // Export each memory
47    for memory in memories {
48        let filename = sanitize_filename(&memory.title);
49        let filepath = memories_dir.join(format!("{}.md", filename));
50
51        let mut content = String::new();
52
53        // YAML frontmatter
54        content.push_str("---\n");
55        content.push_str(&format!("id: {}\n", memory.id));
56        content.push_str(&format!("title: \"{}\"\n", memory.title));
57        content.push_str(&format!("type: {}\n", memory.memory_type));
58        content.push_str(&format!("importance: {}\n", memory.importance));
59        content.push_str(&format!("scope: {}\n", memory.scope));
60        content.push_str(&format!("project: {}\n", memory.project));
61
62        if let Some(ref topic_key) = memory.topic_key {
63            content.push_str(&format!("topic_key: {}\n", topic_key));
64        }
65
66        // Tags as Obsidian tags
67        if !memory.tags.is_empty() {
68            content.push_str(&format!("tags: [{}]\n", memory.tags.join(", ")));
69        }
70
71        // Structured fields
72        if let Some(ref what) = memory.what {
73            content.push_str(&format!("what: \"{}\"\n", what));
74        }
75        if let Some(ref why) = memory.why {
76            content.push_str(&format!("why: \"{}\"\n", why));
77        }
78        if let Some(ref ctx) = memory.context {
79            content.push_str(&format!("context: \"{}\"\n", ctx));
80        }
81        if let Some(ref learned) = memory.learned {
82            content.push_str(&format!("learned: \"{}\"\n", learned));
83        }
84
85        content.push_str(&format!("created_at: {}\n", memory.created_at.to_rfc3339()));
86        content.push_str(&format!("updated_at: {}\n", memory.updated_at.to_rfc3339()));
87
88        // Temporal fields
89        if let Some(ref vf) = memory.valid_from {
90            content.push_str(&format!("valid_from: {}\n", vf.to_rfc3339()));
91        }
92        if let Some(ref vu) = memory.valid_until {
93            content.push_str(&format!("valid_until: {}\n", vu.to_rfc3339()));
94        }
95
96        content.push_str("---\n\n");
97
98        // Content
99        content.push_str(&format!("# {}\n\n", memory.title));
100
101        // Structured fields as markdown
102        if let Some(ref what) = memory.what {
103            content.push_str(&format!("**What:** {}\n\n", what));
104        }
105        if let Some(ref why) = memory.why {
106            content.push_str(&format!("**Why:** {}\n\n", why));
107        }
108
109        content.push_str(&format!("{}\n\n", memory.content));
110
111        if let Some(ref learned) = memory.learned {
112            content.push_str(&format!("**Learned:** {}\n\n", learned));
113        }
114
115        // Tags as Obsidian inline tags
116        if !memory.tags.is_empty() {
117            let tags_line: String = memory
118                .tags
119                .iter()
120                .map(|t| format!("#{}", t.replace(' ', "-")))
121                .collect::<Vec<_>>()
122                .join(" ");
123            content.push_str(&format!("{}\n\n", tags_line));
124        }
125
126        // Wikilinks to related memories
127        if let Some(links) = link_map.get(&memory.id.to_string()) {
128            content.push_str("## Related\n\n");
129            for (target_id, rel_type) in links {
130                if let Some(target) = title_map.get(target_id) {
131                    let target_filename = sanitize_filename(&target.title);
132                    content.push_str(&format!(
133                        "- [[{}|{}]] _({})_\n",
134                        target_filename, target.title, rel_type
135                    ));
136                }
137            }
138            content.push('\n');
139        }
140
141        let content_len = content.len() as u64;
142        std::fs::write(&filepath, content)?;
143        stats.files_written += 1;
144        stats.bytes_written += content_len;
145    }
146
147    // Generate index file
148    let index_content = generate_vault_index(memories, project, &link_map, &title_map);
149    stats.bytes_written += index_content.len() as u64;
150    std::fs::write(vault_root.join("README.md"), index_content)?;
151    stats.files_written += 1;
152
153    // Generate graph view data
154    let graph_data = generate_graph_data(memories, relations);
155    let graph_json = serde_json::to_string_pretty(&graph_data)?;
156    std::fs::write(graph_dir.join("graph.json"), &graph_json)?;
157    stats.bytes_written += graph_json.len() as u64;
158    stats.files_written += 1;
159
160    // Create .obsidian metadata
161    let obsidian_dir = vault_root.join(".obsidian");
162    std::fs::create_dir_all(&obsidian_dir)?;
163    let app_json = serde_json::json!({
164        "baseApp": "obsidian",
165        "version": "1.5.0"
166    });
167    std::fs::write(
168        obsidian_dir.join("app.json"),
169        serde_json::to_string_pretty(&app_json)?,
170    )?;
171
172    Ok(stats)
173}
174
175/// Genera el archivo README.md del vault.
176fn generate_vault_index(
177    memories: &[Memory],
178    project: &str,
179    link_map: &std::collections::HashMap<String, Vec<(String, String)>>,
180    title_map: &std::collections::HashMap<String, &Memory>,
181) -> String {
182    let now = Utc::now().to_rfc3339();
183    let mut content = String::new();
184
185    content.push_str("---\n");
186    content.push_str("title: mneme vault\n");
187    content.push_str(&format!("project: {}\n", project));
188    content.push_str(&format!("exported_at: {}\n", now));
189    content.push_str(&format!("total_memories: {}\n", memories.len()));
190    content.push_str("---\n\n");
191    content.push_str(&format!("# 🧠 Mneme Vault: {}\n\n", project));
192    content.push_str(&format!(
193        "Export generated at {} with {} memories.\n\n",
194        now,
195        memories.len()
196    ));
197
198    // By type
199    let mut by_type: std::collections::HashMap<&MemoryType, Vec<&Memory>> =
200        std::collections::HashMap::new();
201    for memory in memories {
202        by_type.entry(&memory.memory_type).or_default().push(memory);
203    }
204
205    content.push_str("## By Type\n\n");
206    let mut type_keys: Vec<&&MemoryType> = by_type.keys().collect();
207    type_keys.sort_by_key(|t| t.to_string());
208    for t in type_keys {
209        if let Some(mems) = by_type.get(t) {
210            content.push_str(&format!("- **{}** ({}):\n", t, mems.len()));
211            for mem in mems.iter().take(5) {
212                let filename = sanitize_filename(&mem.title);
213                content.push_str(&format!("  - [[{}|{}]]\n", filename, mem.title));
214            }
215            if mems.len() > 5 {
216                content.push_str(&format!("  - _... and {} more_\n", mems.len() - 5));
217            }
218        }
219    }
220
221    content.push_str("\n## Most Connected\n\n");
222    let mut connected: Vec<(String, usize)> = link_map
223        .iter()
224        .map(|(id, links)| (id.clone(), links.len()))
225        .collect();
226    connected.sort_by(|a, b| b.1.cmp(&a.1));
227    for (id, count) in connected.iter().take(10) {
228        if let Some(mem) = title_map.get(id) {
229            let filename = sanitize_filename(&mem.title);
230            content.push_str(&format!(
231                "- [[{}|{}]] ({} connections)\n",
232                filename, mem.title, count
233            ));
234        }
235    }
236
237    content
238}
239
240/// Genera datos de grafo para visualización.
241fn generate_graph_data(memories: &[Memory], relations: &[MemoryRelation]) -> serde_json::Value {
242    let nodes: Vec<serde_json::Value> = memories
243        .iter()
244        .map(|m| {
245            serde_json::json!({
246                "id": m.id.to_string(),
247                "title": m.title,
248                "type": m.memory_type.to_string(),
249                "importance": m.importance.to_string(),
250            })
251        })
252        .collect();
253
254    let edges: Vec<serde_json::Value> = relations
255        .iter()
256        .map(|r| {
257            serde_json::json!({
258                "source": r.source_id.to_string(),
259                "target": r.target_id.to_string(),
260                "type": r.relation_type.to_string(),
261                "confidence": r.confidence,
262            })
263        })
264        .collect();
265
266    serde_json::json!({ "nodes": nodes, "edges": edges })
267}
268
269/// Sanitiza un string para usar como nombre de archivo.
270fn sanitize_filename(s: &str) -> String {
271    s.chars()
272        .map(|c| {
273            if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' || c == '.' {
274                c
275            } else {
276                '-'
277            }
278        })
279        .collect::<String>()
280        .trim()
281        .to_string()
282}
283
284/// Estadísticas de la exportación.
285#[derive(Debug, Clone, Default)]
286pub struct ObsidianExportStats {
287    pub files_written: u32,
288    pub bytes_written: u64,
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::store::memory::{Importance, Scope};
295    use std::str::FromStr;
296
297    fn make_test_memory(id: uuid::Uuid, title: &str, memory_type: &str) -> Memory {
298        Memory {
299            id,
300            project: "test".to_string(),
301            scope: Scope::Project,
302            title: title.to_string(),
303            content: format!("Content of {}", title),
304            what: Some(format!("What: {}", title)),
305            why: Some(format!("Why: {}", title)),
306            context: None,
307            learned: Some(format!("Learned: {}", title)),
308            memory_type: MemoryType::from_str(memory_type).unwrap_or(MemoryType::Note),
309            importance: Importance::High,
310            tags: vec!["test".to_string(), "obsidian".to_string()],
311            topic_key: Some(format!("test/{}", title.to_lowercase())),
312            access_count: 1,
313            revision_count: 1,
314            duplicate_count: 0,
315            normalized_hash: None,
316            created_at: chrono::DateTime::UNIX_EPOCH,
317            updated_at: chrono::DateTime::UNIX_EPOCH,
318            last_accessed_at: None,
319            last_seen_at: None,
320            deleted_at: None,
321            deprecated_at: None,
322            deprecated_reason: None,
323            supersedes_id: None,
324            context_inject_count: 0,
325            origin_peer: None,
326            is_encrypted: false,
327            encrypted_for: None,
328            valid_from: None,
329            valid_until: None,
330            provenance: None,
331        }
332    }
333
334    #[test]
335    fn test_sanitize_filename() {
336        assert_eq!(sanitize_filename("Hello World"), "Hello World");
337        assert_eq!(sanitize_filename("file/name:test"), "file-name-test");
338    }
339
340    #[test]
341    fn test_export_creates_files() {
342        let dir = tempfile::tempdir().unwrap();
343        let mem1 = make_test_memory(uuid::Uuid::new_v4(), "Memory One", "decision");
344        let mem2 = make_test_memory(uuid::Uuid::new_v4(), "Memory Two", "architecture");
345
346        let stats = export_to_obsidian(&[mem1, mem2], &[], "test-project", dir.path()).unwrap();
347
348        assert!(stats.files_written >= 2); // at least 2 memory files
349        assert!(dir.path().join("mneme-export/memories").exists());
350        assert!(dir.path().join("mneme-export/README.md").exists());
351    }
352}