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