1use std::path::Path;
2
3use crate::store::memory::{Memory, MemoryRelation, MemoryType};
4use chrono::Utc;
5
6pub 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 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 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 let title_map: std::collections::HashMap<String, &Memory> =
44 memories.iter().map(|m| (m.id.to_string(), m)).collect();
45
46 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 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 if !memory.tags.is_empty() {
68 content.push_str(&format!("tags: [{}]\n", memory.tags.join(", ")));
69 }
70
71 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 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.push_str(&format!("# {}\n\n", memory.title));
100
101 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 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 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 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 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 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
175fn 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 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
240fn 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
269fn 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#[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); assert!(dir.path().join("mneme-export/memories").exists());
350 assert!(dir.path().join("mneme-export/README.md").exists());
351 }
352}