1use anyhow::{anyhow, Context, Result};
20use base64::{engine::general_purpose::STANDARD as base64, Engine as _};
21use scraper::{Html, Selector};
22use serde::{Deserialize, Serialize};
23use std::fs;
24use std::path::PathBuf;
25use std::process::Command;
26
27#[derive(Deserialize, Serialize, Debug, Clone)]
29pub struct Note {
30 pub title: String,
32 pub content: String,
34 pub folder: String,
36 pub account: String,
38 pub id: String,
40 pub created: String,
42 pub modified: String,
44}
45
46#[derive(Debug, Clone)]
48pub struct ExportConfig {
49 pub output_dir: PathBuf,
51 pub use_attachments: bool,
53 pub filename_format: String,
55 pub subdir_format: String,
57 pub use_subdirs: bool,
59 pub save_html: bool,
61}
62
63impl Default for ExportConfig {
64 fn default() -> Self {
65 Self {
66 output_dir: PathBuf::from("."),
67 use_attachments: true,
68 filename_format: String::from("&title"),
69 subdir_format: String::from("&folder"),
70 use_subdirs: true,
71 save_html: false,
72 }
73 }
74}
75
76pub fn export_notes(config: &ExportConfig) -> Result<Vec<Note>> {
95 fs::create_dir_all(&config.output_dir).context("Failed to create output directory")?;
97
98 let notes = get_notes()?;
100
101 for note in ¬es {
103 let markdown = process_note(note, config)?;
104 save_note(note, &markdown, config)?;
105 }
106
107 Ok(notes)
108}
109
110pub fn get_notes() -> Result<Vec<Note>> {
120 let script_path = PathBuf::from("export-notes.applescript");
121 if !script_path.exists() {
122 return Err(anyhow!(
123 "export-notes.applescript not found in current directory"
124 ));
125 }
126
127 let output = Command::new("osascript")
128 .arg(script_path)
129 .output()
130 .context("Failed to execute AppleScript")?;
131
132 if !output.status.success() {
133 return Err(anyhow!(
134 "AppleScript execution failed: {}",
135 String::from_utf8_lossy(&output.stderr)
136 ));
137 }
138
139 let json_str =
140 String::from_utf8(output.stdout).context("Failed to parse AppleScript output as UTF-8")?;
141
142 let notes: Vec<Note> =
143 serde_json::from_str(&json_str).context("Failed to parse JSON output from AppleScript")?;
144
145 Ok(notes)
146}
147
148pub fn process_note(note: &Note, config: &ExportConfig) -> Result<String> {
161 let html_with_local_images = extract_and_save_images(
163 ¬e.content,
164 &get_note_path(note, config)?,
165 config.use_attachments,
166 )?;
167
168 if config.save_html {
170 save_html(note, &html_with_local_images, config)?;
171 }
172
173 let markdown = html2md::parse_html(&html_with_local_images);
175
176 if note.content.contains("<h1>") {
178 let doc = Html::parse_document(&html_with_local_images);
179 let h1_selector = Selector::parse("h1").unwrap();
180 let h1_texts: Vec<String> = doc
181 .select(&h1_selector)
182 .map(|el| el.text().collect::<String>())
183 .collect();
184
185 if !h1_texts.is_empty() {
186 let joined_text = h1_texts.join("");
187 if !joined_text.trim().is_empty() {
188 return Ok(format!(
189 "# {}\n\n{}",
190 joined_text.trim(),
191 markdown
192 .lines()
193 .filter(|line| !line.starts_with('#'))
194 .collect::<Vec<_>>()
195 .join("\n")
196 ));
197 }
198 }
199 }
200
201 Ok(markdown)
202}
203
204fn get_note_path(note: &Note, config: &ExportConfig) -> Result<PathBuf> {
205 let mut path = config.output_dir.clone();
206
207 if config.use_subdirs {
208 path = path.join(¬e.folder);
209 }
210
211 Ok(path)
212}
213
214fn save_note(note: &Note, markdown: &str, config: &ExportConfig) -> Result<()> {
215 let mut output_path = get_note_path(note, config)?;
216 fs::create_dir_all(&output_path)
217 .with_context(|| format!("Failed to create directory: {:?}", output_path))?;
218
219 let safe_title = note
221 .title
222 .replace(|c: char| !c.is_alphanumeric() && c != '-', "-");
223 output_path = output_path.join(format!("{}.md", safe_title));
224
225 let mut content = String::new();
227 content.push_str("---\n");
228 content.push_str(&format!("title: \"{}\"\n", note.title));
229 content.push_str(&format!("folder: \"{}\"\n", note.folder));
230 content.push_str(&format!("account: \"{}\"\n", note.account));
231 content.push_str(&format!("id: \"{}\"\n", note.id));
232 content.push_str(&format!("created: \"{}\"\n", note.created));
233 content.push_str(&format!("modified: \"{}\"\n", note.modified));
234 content.push_str("---\n\n");
235
236 content.push_str(markdown);
238
239 fs::write(&output_path, content.as_bytes())
241 .with_context(|| format!("Failed to write file: {:?}", output_path))?;
242
243 Ok(())
244}
245
246fn save_html(note: &Note, html: &str, config: &ExportConfig) -> Result<()> {
247 let mut output_path = get_note_path(note, config)?;
248 fs::create_dir_all(&output_path)
249 .with_context(|| format!("Failed to create directory: {:?}", output_path))?;
250
251 let safe_title = note
253 .title
254 .replace(|c: char| !c.is_alphanumeric() && c != '-', "-");
255 output_path = output_path.join(format!("{}.html", safe_title));
256
257 fs::write(&output_path, html.as_bytes())
259 .with_context(|| format!("Failed to write HTML file: {:?}", output_path))?;
260
261 Ok(())
262}
263
264fn extract_and_save_images(
265 html_content: &str,
266 output_dir: &PathBuf,
267 use_attachments: bool,
268) -> Result<String> {
269 let document = Html::parse_document(html_content);
270 let img_selector = Selector::parse("img").unwrap();
271 let mut modified_html = html_content.to_string();
272 let mut img_counter = 0;
273
274 let attachments_dir = if use_attachments {
276 output_dir.join("attachments")
277 } else {
278 output_dir.to_owned()
279 };
280
281 if use_attachments {
283 fs::create_dir_all(&attachments_dir).with_context(|| {
284 format!(
285 "Failed to create attachments directory: {:?}",
286 attachments_dir
287 )
288 })?;
289 }
290
291 for img in document.select(&img_selector) {
293 if let Some(src) = img.value().attr("src") {
294 if src.starts_with("data:image") {
295 img_counter += 1;
296
297 let parts: Vec<&str> = src.split(',').collect();
299 if parts.len() != 2 {
300 continue; }
302
303 let format = parts[0]
305 .split('/')
306 .nth(1)
307 .and_then(|s| s.split(';').next())
308 .unwrap_or("png");
309
310 let image_data = base64
312 .decode(parts[1])
313 .with_context(|| "Failed to decode base64 image data")?;
314
315 let filename = format!("attachment-{:03}.{}", img_counter, format);
317 let image_path = attachments_dir.join(&filename);
318
319 fs::write(&image_path, image_data)
321 .with_context(|| format!("Failed to write image file: {:?}", image_path))?;
322
323 let new_src = if use_attachments {
325 format!("attachments/{}", filename)
326 } else {
327 filename
328 };
329
330 modified_html = modified_html.replace(src, &new_src);
331 }
332 }
333 }
334
335 Ok(modified_html)
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use std::fs;
342 use tempfile::tempdir;
343
344 #[test]
345 fn test_export_config_default() {
346 let config = ExportConfig::default();
347 assert_eq!(config.output_dir, PathBuf::from("."));
348 assert!(config.use_attachments);
349 assert_eq!(config.filename_format, "&title");
350 assert_eq!(config.subdir_format, "&folder");
351 assert!(config.use_subdirs);
352 assert!(!config.save_html);
353 }
354
355 #[test]
356 fn test_process_note_with_images() -> Result<()> {
357 let temp_dir = tempdir()?;
358 let config = ExportConfig {
359 output_dir: temp_dir.path().to_path_buf(),
360 use_attachments: true,
361 filename_format: String::from("&title"),
362 subdir_format: String::from("&folder"),
363 use_subdirs: true,
364 save_html: false,
365 };
366
367 let note = Note {
368 title: String::from("Test Note"),
369 content: String::from(
370 r#"<p>Test content</p><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="/>"#,
371 ),
372 folder: String::from("Test Folder"),
373 account: String::from("Test Account"),
374 id: String::from("test-id"),
375 created: String::from("2024-01-01"),
376 modified: String::from("2024-01-01"),
377 };
378
379 let markdown = process_note(¬e, &config)?;
380 assert!(markdown.contains(""));
381
382 let image_path = temp_dir
384 .path()
385 .join("Test Folder")
386 .join("attachments")
387 .join("attachment-001.png");
388 assert!(image_path.exists());
389
390 Ok(())
391 }
392
393 #[test]
394 fn test_process_note_with_h1() -> Result<()> {
395 let temp_dir = tempdir()?;
396 let config = ExportConfig {
397 output_dir: temp_dir.path().to_path_buf(),
398 use_attachments: true,
399 filename_format: String::from("&title"),
400 subdir_format: String::from("&folder"),
401 use_subdirs: true,
402 save_html: false,
403 };
404
405 let note = Note {
406 title: String::from("Test Note"),
407 content: String::from(
408 "<h1>Title 1</h1><p>Content 1</p><h1>Title 2</h1><p>Content 2</p>",
409 ),
410 folder: String::from("Test Folder"),
411 account: String::from("Test Account"),
412 id: String::from("test-id"),
413 created: String::from("2024-01-01"),
414 modified: String::from("2024-01-01"),
415 };
416
417 let markdown = process_note(¬e, &config)?;
418 assert!(markdown.starts_with("# Title 1Title 2\n\n"));
419 assert!(markdown.contains("Content 1"));
420 assert!(markdown.contains("Content 2"));
421
422 Ok(())
423 }
424
425 #[test]
426 fn test_get_note_path() -> Result<()> {
427 let temp_dir = tempdir()?;
428 let config = ExportConfig {
429 output_dir: temp_dir.path().to_path_buf(),
430 use_attachments: true,
431 filename_format: String::from("&title"),
432 subdir_format: String::from("&folder"),
433 use_subdirs: true,
434 save_html: false,
435 };
436
437 let note = Note {
438 title: String::from("Test Note"),
439 content: String::from("Test content"),
440 folder: String::from("Test Folder"),
441 account: String::from("Test Account"),
442 id: String::from("test-id"),
443 created: String::from("2024-01-01"),
444 modified: String::from("2024-01-01"),
445 };
446
447 let path = get_note_path(¬e, &config)?;
448 assert_eq!(path, temp_dir.path().join("Test Folder"));
449
450 let config_no_subdirs = ExportConfig {
451 use_subdirs: false,
452 ..config
453 };
454 let path_no_subdirs = get_note_path(¬e, &config_no_subdirs)?;
455 assert_eq!(path_no_subdirs, temp_dir.path());
456
457 Ok(())
458 }
459}